diff --git a/Promptly.xcodeproj/project.pbxproj b/Promptly.xcodeproj/project.pbxproj index 958b461..067f5af 100644 --- a/Promptly.xcodeproj/project.pbxproj +++ b/Promptly.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app connects to BLE devices for remote control functionality."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need access to your local network to serve your show script for other members of your crew."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -339,6 +340,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app connects to BLE devices for remote control functionality."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need access to your local network to serve your show script for other members of your crew."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate index e025c7b..0ea6227 100644 Binary files a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate and b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Promptly/Contants.swift b/Promptly/Contants.swift new file mode 100644 index 0000000..2975d81 --- /dev/null +++ b/Promptly/Contants.swift @@ -0,0 +1,32 @@ +// +// Contants.swift +// Promptly +// +// Created by Sasha Bagrov on 01/10/2025. +// + +import Foundation + +struct Constants { + private static let mqttIPKey = "mqttIP" + private static let mqttPortKey = "mqttPort" + + static var mqttIP: String { + get { + UserDefaults.standard.string(forKey: mqttIPKey) ?? "192.168.1.1" + } + set { + UserDefaults.standard.set(newValue, forKey: mqttIPKey) + } + } + + static var mqttPort: Int { + get { + let port = UserDefaults.standard.integer(forKey: mqttPortKey) + return port == 0 ? 1883 : port + } + set { + UserDefaults.standard.set(newValue, forKey: mqttPortKey) + } + } +} diff --git a/Promptly/JSON Server/JSONServer.swift b/Promptly/JSON Server/JSONServer.swift new file mode 100644 index 0000000..e87525a --- /dev/null +++ b/Promptly/JSON Server/JSONServer.swift @@ -0,0 +1,74 @@ +// +// JSONServer.swift +// Promptly +// +// Created by Sasha Bagrov on 01/10/2025. +// + +import Network +import Foundation +import Combine + +class JSONServer: ObservableObject { + let port: NWEndpoint.Port + let listener: NWListener + var connections: [NWConnection] = [] + + init(port: UInt16) { + self.port = NWEndpoint.Port(rawValue: port)! + self.listener = try! NWListener(using: .tcp, on: self.port) + } + + func start(dataToServe: [String: Any]) { + listener.stateUpdateHandler = { state in + switch state { + case .ready: + print("Server ready on port \(self.port)") + case .failed(let error): + print("Server failed: \(error)") + default: + break + } + } + + listener.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection, data: dataToServe) + } + + listener.start(queue: .main) + } + + func stop() { + listener.cancel() + connections.forEach { $0.cancel() } + } + + private func handleConnection(_ connection: NWConnection, data: [String: Any]) { + connections.append(connection) + + connection.start(queue: .main) + + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, context, isComplete, error in + if let jsonData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) { + let response = self?.buildHTTPResponse(body: jsonData) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + } + + private func buildHTTPResponse(body: Data) -> Data { + let header = """ + HTTP/1.1 200 OK\r + Content-Type: application/json\r + Content-Length: \(body.count)\r + Connection: close\r + \r + + """ + var response = header.data(using: .utf8)! + response.append(body) + return response + } +} diff --git a/Promptly/MQTT/MQTTManager.swift b/Promptly/MQTT/MQTTManager.swift index 5312d69..032ebbb 100644 --- a/Promptly/MQTT/MQTTManager.swift +++ b/Promptly/MQTT/MQTTManager.swift @@ -17,6 +17,9 @@ class MQTTManager: ObservableObject { private var messageHandlers: [String: (String) -> Void] = [:] private var cancellables = Set() + private var updateTimers: [String: DispatchSourceTimer] = [:] + private var deviceHeartbeatTimer: DispatchSourceTimer? + private let timerQueue = DispatchQueue(label: "com.promptly.mqtt.timers", qos: .background) func connect(to host: String, port: Int = 1883) { client = MQTTClient( @@ -58,6 +61,10 @@ class MQTTManager: ObservableObject { } func disconnect() { + updateTimers.values.forEach { $0.cancel() } + updateTimers.removeAll() + deviceHeartbeatTimer?.cancel() + deviceHeartbeatTimer = nil client?.disconnect() } @@ -76,10 +83,13 @@ class MQTTManager: ObservableObject { } func subscribeToShowChanges(onChange: @escaping (String, String) -> Void) { - let showsPattern = "shows/+" + let showsPattern = "shows/#" client?.messagePublisher - .filter { $0.topic.hasPrefix("shows/") } + .filter { message in + let components = message.topic.split(separator: "/") + return components.count == 2 && components.first == "shows" + } .sink { message in let topic = message.topic let messageString = message.payload.string ?? "" @@ -94,4 +104,107 @@ class MQTTManager: ObservableObject { client?.subscribe(to: showsPattern) } + + func getShow(id: String, completion: @escaping (String?, String?, String?, String?, String?) -> Void) { + let title = receivedMessages["shows/\(id)/title"] + let location = receivedMessages["shows/\(id)/location"] + let scriptName = receivedMessages["shows/\(id)/scriptName"] + let status = receivedMessages["shows/\(id)/status"] + let dsmNetworkIP = receivedMessages["shows/\(id)/dsmNetworkIP"] + + completion(title, location, scriptName, status, dsmNetworkIP) + } + + func sendOutShow(id: String, title: String?, location: String?, scriptName: String?, status: PerformanceState?, dsmNetworkIP: String?) { + if let title = title { + sendData(to: "shows/\(id)/title", message: title) + } + if let location = location { + sendData(to: "shows/\(id)/location", message: location) + } + if let scriptName = scriptName { + sendData(to: "shows/\(id)/scriptName", message: scriptName) + } + if let dsmNetworkIP = dsmNetworkIP { + sendData(to: "shows/\(id)/dsmNetworkIP", message: dsmNetworkIP) + } + if let status = status { + sendData(to: "shows/\(id)/status", message: status.displayName) + } + + sendData(to: "shows/\(id)/line", message: "0") + sendData(to: "shows/\(id)/calledCues", message: "[]") + sendData(to: "shows/\(id)/timeCalls", message: "") + + sendData(to: "shows/\(id)", message: "updated") + + startPeriodicUpdate(for: id) + } + + func removeShow(id: String) { + stopPeriodicUpdate(for: id) + + let topics = [ + "shows/\(id)/title", + "shows/\(id)/location", + "shows/\(id)/scriptName", + "shows/\(id)/dsmNetworkIP", + "shows/\(id)/status", + "shows/\(id)/line", + "shows/\(id)/calledCues", + "shows/\(id)/timeCalls", + "shows/\(id)" + ] + + topics.forEach { topic in + client?.publish("", to: topic, qos: .atLeastOnce, retain: true) + } + } + + func broadcastDevice(showId: String, deviceUUID: UUID) { + let topic = "shows/\(showId)/devices/\(deviceUUID.uuidString)" + + deviceHeartbeatTimer?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.schedule(deadline: .now(), repeating: 30) + timer.setEventHandler { [weak self] in + self?.sendData(to: topic, message: "heartbeat") + } + timer.resume() + + deviceHeartbeatTimer = timer + } + + func removeDevice(showId: String, deviceUUID: UUID) { + let topic = "shows/\(showId)/devices/\(deviceUUID.uuidString)" + + deviceHeartbeatTimer?.cancel() + deviceHeartbeatTimer = nil + + sendData(to: topic, message: "offline") + } + + func stopBroadcastingDevice() { + deviceHeartbeatTimer?.cancel() + deviceHeartbeatTimer = nil + } + + private func startPeriodicUpdate(for id: String) { + updateTimers[id]?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.schedule(deadline: .now() + 60, repeating: 60) + timer.setEventHandler { [weak self] in + self?.sendData(to: "shows/\(id)", message: "updated") + } + timer.resume() + + updateTimers[id] = timer + } + + func stopPeriodicUpdate(for id: String) { + updateTimers[id]?.cancel() + updateTimers.removeValue(forKey: id) + } } diff --git a/Promptly/Models/Performance.swift b/Promptly/Models/Performance.swift index 4421dc2..dbc397f 100644 --- a/Promptly/Models/Performance.swift +++ b/Promptly/Models/Performance.swift @@ -167,6 +167,40 @@ enum PerformanceState: Codable, Equatable { case stopped } +extension PerformanceState { + init(displayName: String) { + if displayName == "Pre-Show" { + self = .preShow + } else if displayName == "House Open" { + self = .houseOpen + } else if displayName == "Stage Clear" { + self = .clearance + } else if displayName.hasPrefix("Act ") && displayName.hasSuffix(" Running") { + let numberString = displayName + .dropFirst(4) + .dropLast(8) + if let actNumber = Int(numberString) { + self = .inProgress(actNumber: actNumber) + } else { + self = .preShow + } + } else if displayName.hasPrefix("Interval ") { + let numberString = displayName.dropFirst(9) + if let intervalNumber = Int(numberString) { + self = .interval(intervalNumber: intervalNumber) + } else { + self = .preShow + } + } else if displayName == "Show Complete" { + self = .completed + } else if displayName == "Show Stopped" { + self = .stopped + } else { + self = .preShow + } + } +} + @Model class CallSettings { var halfTime: TimeInterval = 35 * 60 diff --git a/Promptly/Models/Script.swift b/Promptly/Models/Script.swift index 46c314b..733d659 100644 --- a/Promptly/Models/Script.swift +++ b/Promptly/Models/Script.swift @@ -219,3 +219,182 @@ enum MarkColor: String, Codable, CaseIterable { } } } + +extension Script { + func toDictionary() -> [String: Any] { + return [ + "id": id.uuidString, + "name": name, + "dateAdded": ISO8601DateFormatter().string(from: dateAdded), + "lines": lines.map { $0.toDictionary() }, + "sections": sections.map { $0.toDictionary() } + ] + } +} + +extension ScriptSection { + func toDictionary() -> [String: Any] { + return [ + "id": id.uuidString, + "title": title, + "type": type.rawValue, + "startLineNumber": startLineNumber, + "endLineNumber": endLineNumber as Any, + "notes": notes + ] + } +} + +extension ScriptLine { + func toDictionary() -> [String: Any] { + return [ + "id": id.uuidString, + "lineNumber": lineNumber, + "content": content, + "elements": elements.map { $0.toDictionary() }, + "cues": cues.map { $0.toDictionary() }, + "isMarked": isMarked, + "markColor": markColor as Any, + "notes": notes + ] + } +} + +extension LineElement { + func toDictionary() -> [String: Any] { + return [ + "id": id.uuidString, + "position": position, + "content": content, + "type": type.rawValue, + "isMarked": isMarked, + "markColor": markColor as Any + ] + } +} + +extension Cue { + func toDictionary() -> [String: Any] { + return [ + "id": id.uuidString, + "lineId": lineId.uuidString, + "position": [ + "elementIndex": position.elementIndex, + "offset": position.offset.rawValue + ], + "type": type.rawValue, + "label": label, + "notes": notes, + "hasAlert": hasAlert, + "alertSound": alertSound as Any + ] + } +} + +extension Script { + convenience init?(from dict: [String: Any]) { + guard let idString = dict["id"] as? String, + let id = UUID(uuidString: idString), + let name = dict["name"] as? String, + let dateAddedString = dict["dateAdded"] as? String, + let dateAdded = ISO8601DateFormatter().date(from: dateAddedString) else { + return nil + } + + self.init(id: id, name: name, dateAdded: dateAdded) + + if let linesArray = dict["lines"] as? [[String: Any]] { + self.lines = linesArray.compactMap { ScriptLine(from: $0) } + } + + if let sectionsArray = dict["sections"] as? [[String: Any]] { + self.sections = sectionsArray.compactMap { ScriptSection(from: $0) } + } + } +} + +extension ScriptSection { + convenience init?(from dict: [String: Any]) { + guard let idString = dict["id"] as? String, + let id = UUID(uuidString: idString), + let title = dict["title"] as? String, + let typeString = dict["type"] as? String, + let type = SectionType(rawValue: typeString), + let startLineNumber = dict["startLineNumber"] as? Int else { + return nil + } + + self.init(id: id, title: title, type: type, startLineNumber: startLineNumber) + + self.endLineNumber = dict["endLineNumber"] as? Int + self.notes = dict["notes"] as? String ?? "" + } +} + +extension ScriptLine { + convenience init?(from dict: [String: Any]) { + guard let idString = dict["id"] as? String, + let id = UUID(uuidString: idString), + let lineNumber = dict["lineNumber"] as? Int, + let content = dict["content"] as? String else { + return nil + } + + self.init(id: id, lineNumber: lineNumber, content: content) + + if let elementsArray = dict["elements"] as? [[String: Any]] { + self.elements = elementsArray.compactMap { LineElement(from: $0) } + } + + if let cuesArray = dict["cues"] as? [[String: Any]] { + self.cues = cuesArray.compactMap { Cue(from: $0) } + } + + self.isMarked = dict["isMarked"] as? Bool ?? false + self.markColor = dict["markColor"] as? String + self.notes = dict["notes"] as? String ?? "" + } +} + +extension LineElement { + convenience init?(from dict: [String: Any]) { + guard let idString = dict["id"] as? String, + let id = UUID(uuidString: idString), + let position = dict["position"] as? Int, + let content = dict["content"] as? String, + let typeString = dict["type"] as? String, + let type = ElementType(rawValue: typeString) else { + return nil + } + + self.init(id: id, position: position, content: content, type: type) + + self.isMarked = dict["isMarked"] as? Bool ?? false + self.markColor = dict["markColor"] as? String + } +} + +extension Cue { + convenience init?(from dict: [String: Any]) { + guard let idString = dict["id"] as? String, + let id = UUID(uuidString: idString), + let lineIdString = dict["lineId"] as? String, + let lineId = UUID(uuidString: lineIdString), + let positionDict = dict["position"] as? [String: Any], + let elementIndex = positionDict["elementIndex"] as? Int, + let offsetString = positionDict["offset"] as? String, + let offset = CueOffset(rawValue: offsetString), + let typeString = dict["type"] as? String, + let type = CueType(rawValue: typeString), + let label = dict["label"] as? String else { + return nil + } + + let position = CuePosition(elementIndex: elementIndex, offset: offset) + self.init(id: id, lineId: lineId, position: position, type: type, label: label) + + self.notes = dict["notes"] as? String ?? "" + self.hasAlert = dict["hasAlert"] as? Bool ?? false + self.alertSound = dict["alertSound"] as? String + } +} diff --git a/Promptly/PromptlyApp.swift b/Promptly/PromptlyApp.swift index 29af1c5..0c326fc 100644 --- a/Promptly/PromptlyApp.swift +++ b/Promptly/PromptlyApp.swift @@ -12,6 +12,9 @@ import SwiftData struct PromptlyApp: App { init() { UIScrollView.appearance().scrollsToTop = false + if UserDefaults.standard.string(forKey: "deviceUUID") == nil { + UserDefaults.standard.set(UUID().uuidString, forKey: "deviceUUID") + } } var body: some Scene { diff --git a/Promptly/Views/.DS_Store b/Promptly/Views/.DS_Store index ef1281b..81f7e80 100644 Binary files a/Promptly/Views/.DS_Store and b/Promptly/Views/.DS_Store differ diff --git a/Promptly/Views/Home Screen/HomeScreenView.swift b/Promptly/Views/Home Screen/HomeScreenView.swift index 01c5218..0c44796 100644 --- a/Promptly/Views/Home Screen/HomeScreenView.swift +++ b/Promptly/Views/Home Screen/HomeScreenView.swift @@ -15,6 +15,7 @@ struct HomeScreenView: View { @State var navStackMessage: String = "" @State var addShow: Bool = false + @State var showNetworkSettings: Bool = false @State var availableShows: [String] = [] @StateObject private var mqttManager = MQTTManager() @@ -35,10 +36,10 @@ struct HomeScreenView: View { .onAppear { self.setupGreeting() - mqttManager.connect(to: "192.168.1.185", port: 1883) + mqttManager.connect(to: Constants.mqttIP, port: Constants.mqttPort) mqttManager.subscribeToShowChanges { showId, message in - if !availableShows.contains(showId) { + if UUID(uuidString: showId) != nil && !availableShows.contains(showId) { availableShows.append(showId) } } @@ -46,6 +47,9 @@ struct HomeScreenView: View { .sheet(isPresented: self.$addShow) { AddShowViewWrapper() } + .sheet(isPresented: self.$showNetworkSettings) { + NetworkSettingsView() + } } } @@ -78,7 +82,7 @@ struct HomeScreenView: View { .foregroundStyle(.secondary) } else { ForEach(availableShows, id: \.self) { showId in - NavigationLink(destination: Text("Join show \(showId)")) { + NavigationLink(destination: MultiPlayerShowDetail(showID: showId, mqttManager: self.mqttManager)) { Text("Show \(showId)") } } @@ -90,13 +94,16 @@ struct HomeScreenView: View { var toolbarContent: some View { Group { + Button { + self.showNetworkSettings = true + } label: { + Label("Network Settings", systemImage: "network") + } + Button { self.addShow = true } label: { - Label( - "Add Show", - systemImage: "plus" - ) + Label("Add Show", systemImage: "plus") } } } @@ -115,6 +122,52 @@ struct HomeScreenView: View { } } +struct NetworkSettingsView: View { + @Environment(\.dismiss) var dismiss + + @State private var mqttIP: String = Constants.mqttIP + @State private var mqttPort: String = String(Constants.mqttPort) + + var body: some View { + NavigationStack { + Form { + Section { + TextField("MQTT IP Address", text: $mqttIP) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("MQTT Port", text: $mqttPort) + .keyboardType(.numberPad) + } header: { + Text("Connection Settings") + } footer: { + Text("To apply changes, restart the app") + .foregroundStyle(.secondary) + } + } + .navigationTitle("Network Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + if let port = Int(mqttPort) { + Constants.mqttIP = mqttIP + Constants.mqttPort = port + } + dismiss() + } + } + } + } + } +} + #Preview { HomeScreenView() } diff --git a/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift b/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift new file mode 100644 index 0000000..ea3318f --- /dev/null +++ b/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift @@ -0,0 +1,147 @@ +// +// MultiPlayerShowDetail.swift +// Promptly +// +// Created by Sasha Bagrov on 01/10/2025. +// + +import SwiftUI + +struct MultiPlayerShowDetail: View { + var showID: String + @StateObject var mqttManager: MQTTManager + + @State private var title: String? + @State private var location: String? + @State private var scriptName: String? + @State private var status: String? + @State private var dsmNetworkIP: String? + @State private var isLoading = false + + @State private var receivedScript: Script? = nil + @State private var showingShow = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if let title = title { + Text(title) + .font(.largeTitle) + .bold() + } + + VStack(alignment: .leading, spacing: 12) { + if let location = location { + HStack { + Text("Location:") + .foregroundColor(.secondary) + Text(location) + } + } + + if let scriptName = scriptName { + HStack { + Text("Script:") + .foregroundColor(.secondary) + Text(scriptName) + } + } + + if let status = status { + HStack { + Text("Status:") + .foregroundColor(.secondary) + Text(status) + .foregroundColor(status == "active" ? .green : .orange) + } + } + + if let dsmNetworkIP = dsmNetworkIP { + HStack { + Text("DSM Network IP:") + .foregroundColor(.secondary) + Text(dsmNetworkIP) + .font(.system(.body, design: .monospaced)) + } + } + } + + Spacer() + + Button(action: { + if receivedScript != nil { + showingShow = true + } else { + isLoading = true + fetchNetwork() { script in + if let script = script { + self.receivedScript = script + } + } + } + }) { + Text(receivedScript != nil ? "Join Show" : "Join") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(10) + } + } + .padding() + .fullScreenCover(isPresented: $isLoading) { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + .fullScreenCover(isPresented: $showingShow) { + if let script = self.receivedScript { + SpectatorPerformanceView(showId: UUID(uuidString: self.showID)!, script: script, mqttManager: self.mqttManager) + } + } + .onAppear { + loadShow() + } + } + + private func loadShow() { + mqttManager.getShow(id: showID) { title, location, scriptName, status, dsmNetworkIP in + self.title = title + self.location = location + self.scriptName = scriptName + self.status = status + self.dsmNetworkIP = dsmNetworkIP + } + } + + private func fetchNetwork(completion: @escaping (Script?) -> Void) { + guard let dsmNetworkIP = dsmNetworkIP, + let url = URL(string: "http://\(dsmNetworkIP):8080") else { + isLoading = false + completion(nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isLoading = false + + guard let data = data, error == nil, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let script = Script(from: json) else { + completion(nil) + return + } + + completion(script) + } + }.resume() + } +} diff --git a/Promptly/Views/Performance Mode/LivePerforemanceView.swift b/Promptly/Views/Performance Mode/LivePerforemanceView.swift index 2cad814..28290fc 100644 --- a/Promptly/Views/Performance Mode/LivePerforemanceView.swift +++ b/Promptly/Views/Performance Mode/LivePerforemanceView.swift @@ -1,5 +1,9 @@ import SwiftUI import SwiftData +import Foundation +import Network +import Darwin +import Combine struct DSMPerformanceView: View { @Environment(\.modelContext) private var modelContext @@ -28,9 +32,11 @@ struct DSMPerformanceView: View { @State private var cueExecutions: [ReportCueExecution] = [] @State private var showingCueAlert = false @State private var cueAlertTimer: Timer? + @State private var uuidOfShow: String = "" @FocusState private var isViewFocused: Bool @StateObject private var bluetoothManager = PromptlyBluetoothManager() @StateObject private var mqttManager = MQTTManager() + @StateObject private var jsonServer = JSONServer(port: 8080) private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -119,6 +125,50 @@ struct DSMPerformanceView: View { } var body: some View { + mainContentView + .applyDSMModifiers( + isViewFocused: $isViewFocused, + showingStopAlert: $showingStopAlert, + showingEndConfirmation: $showingEndConfirmation, + showingBluetoothSettings: $showingBluetoothSettings, + showingSettings: $showingSettings, + showingDetails: $showingDetails, + stopReason: $stopReason, + keepDisplayAwake: $keepDisplayAwake, + scrollToChangesActiveLine: $scrollToChangesActiveLine, + performance: performance, + showId: uuidOfShow, + mqttManager: mqttManager, + timing: $performanceTiming, + callsLog: $callsLog, + currentState: $currentState, + isShowRunning: $isShowRunning, + canMakeQuickCalls: canMakeQuickCalls, + bluetoothManager: bluetoothManager, + onAppear: setupView, + onDisappear: cleanupView, + onStateChange: handleStateChange, + onStartShow: startShow, + onStopShow: { showingStopAlert = true }, + onEndShow: { showingEndConfirmation = true }, + onStartInterval: startInterval, + onStartNextAct: startNextAct, + onEmergencyStop: emergencyStop, + onEndPerformance: endPerformance, + onCacheUpdate: updateCuesCache, + onScriptChange: handleScriptChange, + onLineMove: moveToLine, + onCueExecute: executeNextCue, + timer: timer, + currentTime: $currentTime, + allCues: allCues, + hiddenCues: hiddenCues, + sortedLinesCache: sortedLinesCache, + script: script + ) + } + + private var mainContentView: some View { GeometryReader { geometry in ZStack { VStack(spacing: 0) { @@ -162,105 +212,77 @@ struct DSMPerformanceView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .navigationBarHidden(true) - .preferredColorScheme(.dark) - .focusable() - .focused($isViewFocused) - .onKeyPress(.downArrow) { - withAnimation(.easeOut(duration: 0.1)) { - moveToLine(currentLineNumber + 1) - } - return .handled - } - .onKeyPress(.upArrow) { - withAnimation(.easeOut(duration: 0.1)) { - moveToLine(currentLineNumber - 1) - } - return .handled + } + + private func setupView() { + isViewFocused = true + if keepDisplayAwake { + UIApplication.shared.isIdleTimerDisabled = true } - .onAppear { - isViewFocused = true - if keepDisplayAwake { - UIApplication.shared.isIdleTimerDisabled = true - } - sortedLinesCache = script?.lines.sorted { $0.lineNumber < $1.lineNumber } ?? [] - updateLinesCache() - loadAllCues() - updateCuesCache() + sortedLinesCache = script?.lines.sorted { $0.lineNumber < $1.lineNumber } ?? [] + updateLinesCache() + loadAllCues() + updateCuesCache() + + mqttManager.connect(to: Constants.mqttIP, port: Constants.mqttPort) + + if let show = performance.show { + guard let scriptDict = show.script?.toDictionary() else { + print("well fuck uhhhh my guy u are cookido") + return + } + jsonServer.start(dataToServe: scriptDict) - mqttManager.connect(to: "192.168.1.185", port: 1883) + mqttManager.sendOutShow( + id: show.id.uuidString, + title: show.title, + location: show.locationString, + scriptName: show.script?.name, + status: currentState, + dsmNetworkIP: getLocalIPAddress() ?? "N/A" + ) - bluetoothManager.onButtonPress = { value in - if value == "1" { - withAnimation(.easeOut(duration: 0.1)) { - moveToLine(currentLineNumber + 1) - } - } else if value == "0" { - withAnimation(.easeOut(duration: 0.1)) { - moveToLine(currentLineNumber - 1) - } - } else if value == "2" { - executeNextCue() - } - } - } - .onChange(of: allCues) { _, _ in updateCuesCache() } - .onChange(of: hiddenCues) { _, _ in updateCuesCache() } - .onChange(of: sortedLinesCache) { _, _ in updateCuesCache() } - .onChange(of: script) { _, _ in - updateLinesCache() - loadAllCues() - updateCuesCache() - } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - for timer in cueHideTimers.values { - timer.invalidate() - } - cueHideTimers.removeAll() - cueAlertTimer?.invalidate() - } - .onReceive(timer) { _ in - currentTime = Date() - } - .alert("Emergency Stop", isPresented: $showingStopAlert) { - TextField("Reason", text: $stopReason) - Button("Cancel", role: .cancel) { } - Button("Stop Show", role: .destructive) { - emergencyStop() - } + let showUUID = show.id.uuidString + print("🚀 Setting uuidOfShow to: '\(showUUID)'") + uuidOfShow = showUUID + + print("🚀 Sending initial line with UUID: '\(showUUID)'") + mqttManager.sendData(to: "shows/\(showUUID)/line", message: "1") } - .alert("End Performance", isPresented: $showingEndConfirmation) { - Button("Cancel", role: .cancel) { } - Button("End Show") { - endPerformance() + + bluetoothManager.onButtonPress = { value in + if value == "1" { + withAnimation(.easeOut(duration: 0.1)) { + moveToLine(currentLineNumber + 1) + } + } else if value == "0" { + withAnimation(.easeOut(duration: 0.1)) { + moveToLine(currentLineNumber - 1) + } + } else if value == "2" { + executeNextCue() } } - .sheet(isPresented: $showingBluetoothSettings) { - PromptlyBluetoothSettingsView(bluetoothManager: bluetoothManager) - } - .sheet(isPresented: $showingSettings) { - DSMSettingsView( - keepDisplayAwake: $keepDisplayAwake, - scrollToChangesActiveLine: $scrollToChangesActiveLine - ) - } - .sheet(isPresented: $showingDetails) { - DSMDetailsView( - performance: performance, - timing: $performanceTiming, - callsLog: $callsLog, - currentState: $currentState, - isShowRunning: $isShowRunning, - canMakeQuickCalls: canMakeQuickCalls, - onStartShow: { startShow() }, - onStopShow: { showingStopAlert = true }, - onEndShow: { showingEndConfirmation = true }, - onStartInterval: { startInterval() }, - onStartNextAct: { startNextAct() } - ) + } + + private func cleanupView() { + UIApplication.shared.isIdleTimerDisabled = false + for timer in cueHideTimers.values { + timer.invalidate() } + cueHideTimers.removeAll() + cueAlertTimer?.invalidate() + } + + private func handleStateChange() { + mqttManager.sendData(to: "shows/\(uuidOfShow)/status", message: currentState.displayName) + } + + private func handleScriptChange() { + updateLinesCache() + loadAllCues() + updateCuesCache() } private var compactHeader: some View { @@ -484,78 +506,11 @@ struct DSMPerformanceView: View { } } - // private var scriptContentView: some View { - // ScrollViewReader { proxy in - // ScrollView { - // LazyVStack(alignment: .leading, spacing: 8) { - // ForEach(groupLinesBySection(), id: \.stableId) { group in - // if let section = group.section { - // DSMSectionHeaderView(section: section) - // .id("section-\(section.id)") - // } - // - // // ForEach(group.lines, id: \.id) { line in - // // DSMScriptLineView( - // // line: line, - // // isCurrent: line.lineNumber == currentLineNumber, - // // onLineTap: { - // // currentLineNumber = line.lineNumber - // // }, - // // calledCues: calledCues - // // ) - // // .id("line-\(line.lineNumber)") - // // } - // - // // ForEach(group.lines, id: \.id) { line in - // // if abs(line.lineNumber - currentLineNumber) < 50 { - // // DSMScriptLineView( - // // line: line, - // // isCurrent: line.lineNumber == currentLineNumber, - // // onLineTap: { - // // currentLineNumber = line.lineNumber - // // }, - // // calledCues: calledCues - // // ) - // // } - // // } - // - // ForEach(group.lines.prefix(500), id: \.id) { line in - // Text("L\(line.lineNumber): \(line.content)") - // .padding(.vertical, 4) - // .id("line-\(line.lineNumber)") - // } - // } - // } - // .padding() - // } - // .onChange(of: currentLineNumber) { _, newValue in - // withAnimation(.easeOut(duration: 0.15)) { - // proxy.scrollTo("line-\(newValue)", anchor: .center) - // } - // } - // } - // } - - // private var scriptContentView: some View { - // ScrollViewReader { proxy in - // ScrollView { - // LazyVStack(alignment: .leading, spacing: 8) { - // ForEach(sortedLinesCache.prefix(300), id: \.id) { line in - // Text("L\(line.lineNumber): \(line.content)") - // .padding(.vertical, 4) - // .id("line-\(line.lineNumber)") - // } - // } - // .padding() - // } - // .onChange(of: currentLineNumber) { _, newValue in - // proxy.scrollTo("line-\(newValue)", anchor: .center) - // } - // } - // } - private var scriptContentView: some View { - ScrollViewReader { proxy in + let showUUID = self.uuidOfShow + print("🔍 scriptContentView rendering - uuidOfShow: '\(showUUID)'") + + return ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(sortedLinesCache, id: \.id) { line in @@ -563,8 +518,12 @@ struct DSMPerformanceView: View { line: line, isCurrent: line.lineNumber == currentLineNumber, onLineTap: { + print("🎯 Tapped line \(line.lineNumber)") + print("🔍 showUUID in closure: '\(showUUID)'") + print("🔍 self.uuidOfShow in closure: '\(self.uuidOfShow)'") + currentLineNumber = line.lineNumber - self.mqttManager.sendData(to: "shows/1/line", message: "\(line.lineNumber)") + self.mqttManager.sendData(to: "shows/\(showUUID)/line", message: "\(line.lineNumber)") }, calledCues: calledCues ) @@ -574,7 +533,7 @@ struct DSMPerformanceView: View { .padding() .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(Color(.systemBackground)) // improves gesture hitbox + .background(Color(.systemBackground)) .onChange(of: currentLineNumber) { _, newValue in proxy.scrollTo("line-\(newValue)", anchor: .center) } @@ -722,6 +681,40 @@ struct DSMPerformanceView: View { return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } + + func getLocalIPAddress() -> String? { + var address: String? + var ifaddr: UnsafeMutablePointer? + + guard getifaddrs(&ifaddr) == 0 else { return nil } + guard let firstAddr = ifaddr else { return nil } + + defer { freeifaddrs(ifaddr) } + + for ifptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) { + let interface = ifptr.pointee + let addrFamily = interface.ifa_addr.pointee.sa_family + + if addrFamily == UInt8(AF_INET) { + let name = String(cString: interface.ifa_name) + + if name == "en0" || name == "en1" || name.starts(with: "en") { + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + getnameinfo(interface.ifa_addr, + socklen_t(interface.ifa_addr.pointee.sa_len), + &hostname, + socklen_t(hostname.count), + nil, + socklen_t(0), + NI_NUMERICHOST) + address = String(cString: hostname) + break + } + } + } + + return address + } } struct DSMSectionHeaderView: View { @@ -1104,6 +1097,8 @@ struct DSMCueBoxView: View { struct DSMDetailsView: View { let performance: Performance + let showId: String + @ObservedObject var mqttManager: MQTTManager @Binding var timing: PerformanceTiming @Binding var callsLog: [CallLogEntry] @Binding var currentState: PerformanceState @@ -1171,6 +1166,7 @@ struct DSMDetailsView: View { timing.houseOpenTime = Date() timing.currentState = .houseOpen logCall("House Open", type: .action) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "House Open") } } @@ -1185,6 +1181,7 @@ struct DSMDetailsView: View { timing.clearanceTime = Date() timing.currentState = .clearance logCall("Stage Clear - Beginners", type: .call) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "Stage Clear") } } @@ -1268,15 +1265,19 @@ struct DSMDetailsView: View { ], spacing: 12) { DSMCallButton(title: "Half Hour", time: "35 min") { logCall("Half Hour Call", type: .call) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "Half Hour Call (35 min)") } DSMCallButton(title: "Quarter Hour", time: "20 min") { logCall("Quarter Hour Call", type: .call) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "Quarter Hour Call (20 min)") } DSMCallButton(title: "Five Minutes", time: "10 min") { logCall("Five Minutes Call", type: .call) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "Five Minutes Call (10 min)") } DSMCallButton(title: "Beginners", time: "5 min") { logCall("Beginners Call", type: .call) + self.mqttManager.sendData(to: "shows/\(showId)/timeCalls", message: "Beginners Call (5 min)") } } } @@ -1513,7 +1514,8 @@ extension DSMPerformanceView { try? modelContext.save() // Dismiss the DSM view after a brief delay to show the completion state - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.mqttManager.removeShow(id: self.uuidOfShow) dismiss() } } @@ -1615,6 +1617,13 @@ extension DSMPerformanceView { } cueHideTimers[cue.id] = timer } + + // SEND THE UPDATE HERE + let uuidStrings = calledCues.map { $0.uuidString } + if let jsonData = try? JSONEncoder().encode(uuidStrings), + let jsonString = String(data: jsonData, encoding: .utf8) { + mqttManager.sendData(to: "shows/\(uuidOfShow)/calledCues", message: jsonString) + } } private func executeNextCue() { @@ -1640,6 +1649,13 @@ extension DSMPerformanceView { } else { logCall("REMOTE GO: \(cue.label)", type: .action) } + + // SEND THE UPDATE HERE TOO + let uuidStrings = calledCues.map { $0.uuidString } + if let jsonData = try? JSONEncoder().encode(uuidStrings), + let jsonString = String(data: jsonData, encoding: .utf8) { + mqttManager.sendData(to: "shows/\(uuidOfShow)/calledCues", message: jsonString) + } } private func findNextCueFromCurrentLine() -> Cue? { @@ -1658,7 +1674,7 @@ extension DSMPerformanceView { private func moveToLine(_ lineNumber: Int) { guard lineNumber >= 1 && lineNumber <= sortedLinesCache.count else { return } currentLineNumber = lineNumber - self.mqttManager.sendData(to: "shows/1/line", message: String(lineNumber)) + self.mqttManager.sendData(to: "shows/\(self.uuidOfShow)/line", message: String(lineNumber)) } private func logCall(_ message: String, type: CallLogEntry.CallType = .note) { @@ -1732,3 +1748,108 @@ extension CueType { } +extension View { + func applyDSMModifiers( + isViewFocused: FocusState.Binding, + showingStopAlert: Binding, + showingEndConfirmation: Binding, + showingBluetoothSettings: Binding, + showingSettings: Binding, + showingDetails: Binding, + stopReason: Binding, + keepDisplayAwake: Binding, + scrollToChangesActiveLine: Binding, + performance: Performance, + showId: String, + mqttManager: MQTTManager, + timing: Binding, + callsLog: Binding<[CallLogEntry]>, + currentState: Binding, + isShowRunning: Binding, + canMakeQuickCalls: Bool, + bluetoothManager: PromptlyBluetoothManager, + onAppear: @escaping () -> Void, + onDisappear: @escaping () -> Void, + onStateChange: @escaping () -> Void, + onStartShow: @escaping () -> Void, + onStopShow: @escaping () -> Void, + onEndShow: @escaping () -> Void, + onStartInterval: @escaping () -> Void, + onStartNextAct: @escaping () -> Void, + onEmergencyStop: @escaping () -> Void, + onEndPerformance: @escaping () -> Void, + onCacheUpdate: @escaping () -> Void, + onScriptChange: @escaping () -> Void, + onLineMove: @escaping (Int) -> Void, + onCueExecute: @escaping () -> Void, + timer: Publishers.Autoconnect, + currentTime: Binding, + allCues: [Cue], + hiddenCues: Set, + sortedLinesCache: [ScriptLine], + script: Script? + ) -> some View { + self + .navigationBarHidden(true) + .preferredColorScheme(.dark) + .focusable() + .focused(isViewFocused) + .onKeyPress(.downArrow) { + withAnimation(.easeOut(duration: 0.1)) { + onLineMove(sortedLinesCache.first(where: { $0.lineNumber > sortedLinesCache.first?.lineNumber ?? 0 })?.lineNumber ?? 1) + } + return .handled + } + .onKeyPress(.upArrow) { + withAnimation(.easeOut(duration: 0.1)) { + onLineMove(sortedLinesCache.first(where: { $0.lineNumber < sortedLinesCache.first?.lineNumber ?? 0 })?.lineNumber ?? 1) + } + return .handled + } + .onAppear(perform: onAppear) + .onChange(of: allCues) { _, _ in onCacheUpdate() } + .onChange(of: hiddenCues) { _, _ in onCacheUpdate() } + .onChange(of: sortedLinesCache) { _, _ in onCacheUpdate() } + .onChange(of: script) { _, _ in onScriptChange() } + .onChange(of: currentState.wrappedValue) { _, _ in onStateChange() } + .onDisappear(perform: onDisappear) + .onReceive(timer) { _ in + currentTime.wrappedValue = Date() + } + .alert("Emergency Stop", isPresented: showingStopAlert) { + TextField("Reason", text: stopReason) + Button("Cancel", role: .cancel) { } + Button("Stop Show", role: .destructive, action: onEmergencyStop) + } + .alert("End Performance", isPresented: showingEndConfirmation) { + Button("Cancel", role: .cancel) { } + Button("End Show", action: onEndPerformance) + } + .sheet(isPresented: showingBluetoothSettings) { + PromptlyBluetoothSettingsView(bluetoothManager: bluetoothManager) + } + .sheet(isPresented: showingSettings) { + DSMSettingsView( + keepDisplayAwake: keepDisplayAwake, + scrollToChangesActiveLine: scrollToChangesActiveLine + ) + } + .sheet(isPresented: showingDetails) { + DSMDetailsView( + performance: performance, + showId: showId, + mqttManager: mqttManager, + timing: timing, + callsLog: callsLog, + currentState: currentState, + isShowRunning: isShowRunning, + canMakeQuickCalls: canMakeQuickCalls, + onStartShow: onStartShow, + onStopShow: onStopShow, + onEndShow: onEndShow, + onStartInterval: onStartInterval, + onStartNextAct: onStartNextAct + ) + } + } +} diff --git a/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift b/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift new file mode 100644 index 0000000..3e80ecb --- /dev/null +++ b/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift @@ -0,0 +1,199 @@ +// +// SpectatorPerformaceView.swift +// Promptly +// +// Created by Sasha Bagrov on 01/10/2025. +// + +import SwiftUI +import SwiftData + +struct SpectatorPerformanceView: View { + let showId: UUID + let script: Script + + @StateObject var mqttManager: MQTTManager + @State private var currentLine: Int = 1 + @State private var status: PerformanceState = .preShow + @State private var calledCues: Set = [] + @State private var timeCalls: String = "" + @State private var sortedLinesCache: [ScriptLine] = [] + @State private var currentTime = Date() + @State private var showingTimeCall = false + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + @Environment(\.dismiss) var dismiss + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + spectatorHeader + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(sortedLinesCache, id: \.id) { line in + DSMScriptLineView( + line: line, + isCurrent: line.lineNumber == currentLine, + onLineTap: {}, + calledCues: calledCues + ) + .id("line-\(line.lineNumber)") + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .background(Color(.systemBackground)) + .onChange(of: currentLine) { _, newValue in + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("line-\(newValue)", anchor: .center) + } + } + } + } + } + .navigationBarHidden(true) + .preferredColorScheme(.dark) + .fullScreenCover(isPresented: $showingTimeCall) { + TimeCallOverlay(message: timeCalls) { + showingTimeCall = false + } + } + .onAppear { + sortedLinesCache = script.lines.sorted { $0.lineNumber < $1.lineNumber } + + mqttManager.subscribe(to: "shows/\(showId.uuidString)/line") { message in + if let lineNum = Int(message) { + currentLine = lineNum + } + } + + mqttManager.subscribe(to: "shows/\(showId.uuidString)/status") { message in + status = PerformanceState(displayName: message) + } + + mqttManager.subscribe(to: "shows/\(showId.uuidString)/calledCues") { message in + guard let data = message.data(using: .utf8), + let uuidStrings = try? JSONDecoder().decode([String].self, from: data) + else { return } + + calledCues = Set(uuidStrings.compactMap { UUID(uuidString: $0) }) + } + + mqttManager.subscribe(to: "shows/\(showId.uuidString)/timeCalls") { message in + timeCalls = message + if !message.isEmpty { + showingTimeCall = true + } + } + + if let deviceUUID = UUID(uuidString: UserDefaults.standard.string(forKey: "deviceUUID") ?? "") { + mqttManager.broadcastDevice( + showId: showId.uuidString, + deviceUUID: deviceUUID + ) + } + } + .onReceive(timer) { _ in + currentTime = Date() + } + .onChange(of: self.status) { oldStatus, newStatus in + if newStatus == .completed { + Task { + try? await Task.sleep(for: .seconds(2)) + if let deviceUUID = UUID(uuidString: UserDefaults.standard.string(forKey: "deviceUUID") ?? "") { + mqttManager.removeDevice( + showId: showId.uuidString, + deviceUUID: deviceUUID + ) + } + dismiss() + } + } + } + } + + private var spectatorHeader: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(script.name) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.primary) + + HStack(spacing: 8) { + Text(status.displayName) + .font(.caption) + .foregroundColor(status.color) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(status.color.opacity(0.2)) + .cornerRadius(4) + + Text("Spectator Mode") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(currentTime.formatted(date: .omitted, time: .standard)) + .font(.title2) + .fontWeight(.bold) + .monospacedDigit() + .foregroundColor(.primary) + + Text("Line \(currentLine)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + } + .padding() + .background(Color(.systemGray6)) + .overlay(alignment: .bottom) { + Rectangle() + .frame(height: 1) + .foregroundColor(Color(.separator)) + } + } +} + +struct TimeCallOverlay: View { + let message: String + let onDismiss: () -> Void + + var body: some View { + ZStack { + Color.red + .ignoresSafeArea() + + VStack { + HStack { + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.white) + } + .padding(30) + } + + Spacer() + + Text(message) + .font(.system(size: 80, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(40) + + Spacer() + } + } + } +}