diff --git a/.DS_Store b/.DS_Store index 402e1dd..8e46901 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Promptly-WatchOS Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/Promptly-WatchOS Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Promptly-WatchOS Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing copy.png b/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing copy.png new file mode 100644 index 0000000..02a66f2 Binary files /dev/null and b/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing copy.png differ diff --git a/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..01400d6 --- /dev/null +++ b/Promptly-WatchOS Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon~ios-marketing copy.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Promptly-WatchOS Watch App/Assets.xcassets/Contents.json b/Promptly-WatchOS Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Promptly-WatchOS Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Promptly-WatchOS Watch App/ContentView.swift b/Promptly-WatchOS Watch App/ContentView.swift new file mode 100644 index 0000000..016f4a5 --- /dev/null +++ b/Promptly-WatchOS Watch App/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// Promptly-WatchOS Watch App +// +// Created by Sasha Bagrov on 05/10/2025. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/Promptly-WatchOS Watch App/HomeScreenView.swift b/Promptly-WatchOS Watch App/HomeScreenView.swift new file mode 100644 index 0000000..8a406ad --- /dev/null +++ b/Promptly-WatchOS Watch App/HomeScreenView.swift @@ -0,0 +1,138 @@ +// +// HomeScreenView.swift +// Promptly-WatchOS Watch App +// +// Created by Sasha Bagrov on 05/10/2025. +// + +import SwiftUI +import SwiftData + +struct HomeScreenView: View { + @State var navStackMessage: String = "" + @State var showNetworkSettings: Bool = false + @State var availableShows: [String: String] = [:] + + @StateObject private var mqttManager = MQTTManager() + + var body: some View { + NavigationStack { + Group { + self.content + } + .navigationTitle(Text( + self.navStackMessage + )) + .toolbar { + ToolbarItemGroup { + self.toolbarContent + } + } + .onAppear { + self.setupGreeting() + + mqttManager.connect(to: Constants.mqttIP, port: Constants.mqttPort) + + mqttManager.subscribeToShowChanges { showId, property, message, title in + if UUID(uuidString: showId) != nil && availableShows[showId] == nil { + availableShows[showId] = title ?? "Unknown Show" + } + } + } + .sheet(isPresented: self.$showNetworkSettings) { + NetworkSettingsView() + } + } + } + + var content: some View { + Group { + List { + Section(header: Text("Or join a show")) { + if availableShows.isEmpty { + Text("No available shows") + .foregroundStyle(.secondary) + } else { + ForEach(Array(availableShows.keys), id: \.self) { showId in + NavigationLink(destination: MultiPlayerShowDetail(showID: showId, mqttManager: self.mqttManager)) { + Text(availableShows[showId] ?? "Unknown Show") + } + } + } + } + } + } + } + + var toolbarContent: some View { + Group { + Button { + self.showNetworkSettings = true + } label: { + Label("Network Settings", systemImage: "network") + } + } + } + + private func setupGreeting() { + let hour = Calendar.current.component(.hour, from: Date()) + if hour >= 5 && hour < 12 { + self.navStackMessage = "Good Morning" + } else if hour >= 12 && hour < 17 { + self.navStackMessage = "Good Afternoon" + } else if hour >= 17 && hour < 21 { + self.navStackMessage = "Good Evening" + } else { + self.navStackMessage = "Good Night" + } + } +} + +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) + } 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-WatchOS Watch App/Promptly_WatchOSApp.swift b/Promptly-WatchOS Watch App/Promptly_WatchOSApp.swift new file mode 100644 index 0000000..74445ca --- /dev/null +++ b/Promptly-WatchOS Watch App/Promptly_WatchOSApp.swift @@ -0,0 +1,17 @@ +// +// Promptly_WatchOSApp.swift +// Promptly-WatchOS Watch App +// +// Created by Sasha Bagrov on 05/10/2025. +// + +import SwiftUI + +@main +struct Promptly_WatchOS_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + HomeScreenView() + } + } +} diff --git a/Promptly.xcodeproj/project.pbxproj b/Promptly.xcodeproj/project.pbxproj index 067f5af..fa3a15c 100644 --- a/Promptly.xcodeproj/project.pbxproj +++ b/Promptly.xcodeproj/project.pbxproj @@ -7,12 +7,38 @@ objects = { /* Begin PBXBuildFile section */ - 6F624EBF2DF370D700D17791 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = 6F624EBE2DF370D700D17791 /* OpenAI */; }; - 6F624EC22DF373FF00D17791 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 6F624EC12DF373FF00D17791 /* Yams */; }; + 6F8BC3002E927EA7008EE618 /* Promptly-WatchOS Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 6F8BC2F62E927EA5008EE618 /* Promptly-WatchOS Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 6F8BC30B2E927FED008EE618 /* MQTTNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6F8BC30A2E927FED008EE618 /* MQTTNIO */; }; + 6F9232672EA2A9E500D929A7 /* MIDIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6F9232662EA2A9E500D929A7 /* MIDIKit */; }; + 6F9232692EA2A9E500D929A7 /* MIDIKitControlSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = 6F9232682EA2A9E500D929A7 /* MIDIKitControlSurfaces */; }; + 6F92326B2EA2A9E500D929A7 /* MIDIKitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 6F92326A2EA2A9E500D929A7 /* MIDIKitCore */; }; + 6F92326D2EA2A9E500D929A7 /* MIDIKitIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6F92326C2EA2A9E500D929A7 /* MIDIKitIO */; }; + 6F92326F2EA2A9E500D929A7 /* MIDIKitSMF in Frameworks */ = {isa = PBXBuildFile; productRef = 6F92326E2EA2A9E500D929A7 /* MIDIKitSMF */; }; 6FAE1C952E89E0A500D067BE /* MQTTNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6FAE1C942E89E0A500D067BE /* MQTTNIO */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 6F8BC2FE2E927EA7008EE618 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6F0425B12DF0BFE5002B2081 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6F8BC2F52E927EA5008EE618; + remoteInfo = "Promptly-WatchOS Watch App"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ + 6F8BC3042E927EA7008EE618 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 6F8BC3002E927EA7008EE618 /* Promptly-WatchOS Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 6FF2CAA92E00C48A00AC265D /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -27,14 +53,42 @@ /* Begin PBXFileReference section */ 6F0425B92DF0BFE5002B2081 /* Promptly.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Promptly.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6F8BC2F62E927EA5008EE618 /* Promptly-WatchOS Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Promptly-WatchOS Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 6F8BC3082E927F26008EE618 /* Exceptions for "Promptly" folder in "Promptly-WatchOS Watch App" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Contants.swift, + Models/Errors.swift, + Models/Performance.swift, + Models/Script.swift, + Models/Show.swift, + MQTT/MQTTManager.swift, + Views/Multiplayer/MultiPlayerShowDetail.swift, + "Views/Performance Mode/CueTagView.swift", + "Views/Performance Mode/DSMScriptLineView.swift", + "Views/Performance Mode/SpectatorPerformaceView.swift", + ); + target = 6F8BC2F52E927EA5008EE618 /* Promptly-WatchOS Watch App */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 6F0425BB2DF0BFE5002B2081 /* Promptly */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 6F8BC3082E927F26008EE618 /* Exceptions for "Promptly" folder in "Promptly-WatchOS Watch App" target */, + ); path = Promptly; sourceTree = ""; }; + 6F8BC2F72E927EA5008EE618 /* Promptly-WatchOS Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Promptly-WatchOS Watch App"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,9 +96,20 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6F9232692EA2A9E500D929A7 /* MIDIKitControlSurfaces in Frameworks */, + 6F92326D2EA2A9E500D929A7 /* MIDIKitIO in Frameworks */, + 6F9232672EA2A9E500D929A7 /* MIDIKit in Frameworks */, + 6F92326F2EA2A9E500D929A7 /* MIDIKitSMF in Frameworks */, 6FAE1C952E89E0A500D067BE /* MQTTNIO in Frameworks */, - 6F624EC22DF373FF00D17791 /* Yams in Frameworks */, - 6F624EBF2DF370D700D17791 /* OpenAI in Frameworks */, + 6F92326B2EA2A9E500D929A7 /* MIDIKitCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6F8BC2F32E927EA5008EE618 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6F8BC30B2E927FED008EE618 /* MQTTNIO in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -55,6 +120,7 @@ isa = PBXGroup; children = ( 6F0425BB2DF0BFE5002B2081 /* Promptly */, + 6F8BC2F72E927EA5008EE618 /* Promptly-WatchOS Watch App */, 6FAE1C812E89DE6300D067BE /* Frameworks */, 6F0425BA2DF0BFE5002B2081 /* Products */, ); @@ -64,6 +130,7 @@ isa = PBXGroup; children = ( 6F0425B92DF0BFE5002B2081 /* Promptly.app */, + 6F8BC2F62E927EA5008EE618 /* Promptly-WatchOS Watch App.app */, ); name = Products; sourceTree = ""; @@ -86,24 +153,52 @@ 6F0425B62DF0BFE5002B2081 /* Frameworks */, 6F0425B72DF0BFE5002B2081 /* Resources */, 6FF2CAA92E00C48A00AC265D /* Embed Frameworks */, + 6F8BC3042E927EA7008EE618 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + 6F8BC2FF2E927EA7008EE618 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 6F0425BB2DF0BFE5002B2081 /* Promptly */, ); name = Promptly; packageProductDependencies = ( - 6F624EBE2DF370D700D17791 /* OpenAI */, - 6F624EC12DF373FF00D17791 /* Yams */, 6FAE1C942E89E0A500D067BE /* MQTTNIO */, + 6F9232662EA2A9E500D929A7 /* MIDIKit */, + 6F9232682EA2A9E500D929A7 /* MIDIKitControlSurfaces */, + 6F92326A2EA2A9E500D929A7 /* MIDIKitCore */, + 6F92326C2EA2A9E500D929A7 /* MIDIKitIO */, + 6F92326E2EA2A9E500D929A7 /* MIDIKitSMF */, ); productName = Promptly; productReference = 6F0425B92DF0BFE5002B2081 /* Promptly.app */; productType = "com.apple.product-type.application"; }; + 6F8BC2F52E927EA5008EE618 /* Promptly-WatchOS Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6F8BC3012E927EA7008EE618 /* Build configuration list for PBXNativeTarget "Promptly-WatchOS Watch App" */; + buildPhases = ( + 6F8BC2F22E927EA5008EE618 /* Sources */, + 6F8BC2F32E927EA5008EE618 /* Frameworks */, + 6F8BC2F42E927EA5008EE618 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 6F8BC2F72E927EA5008EE618 /* Promptly-WatchOS Watch App */, + ); + name = "Promptly-WatchOS Watch App"; + packageProductDependencies = ( + 6F8BC30A2E927FED008EE618 /* MQTTNIO */, + ); + productName = "Promptly-WatchOS Watch App"; + productReference = 6F8BC2F62E927EA5008EE618 /* Promptly-WatchOS Watch App.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -111,12 +206,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1640; TargetAttributes = { 6F0425B82DF0BFE5002B2081 = { CreatedOnToolsVersion = 16.4; }; + 6F8BC2F52E927EA5008EE618 = { + CreatedOnToolsVersion = 26.0.1; + }; }; }; buildConfigurationList = 6F0425B42DF0BFE5002B2081 /* Build configuration list for PBXProject "Promptly" */; @@ -129,9 +227,8 @@ mainGroup = 6F0425B02DF0BFE5002B2081; minimizedProjectReferenceProxies = 1; packageReferences = ( - 6F624EBD2DF370D700D17791 /* XCRemoteSwiftPackageReference "OpenAI" */, - 6F624EC02DF373FF00D17791 /* XCRemoteSwiftPackageReference "Yams" */, 6FAE1C932E89E0A500D067BE /* XCRemoteSwiftPackageReference "mqtt-nio" */, + 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F0425BA2DF0BFE5002B2081 /* Products */; @@ -139,6 +236,7 @@ projectRoot = ""; targets = ( 6F0425B82DF0BFE5002B2081 /* Promptly */, + 6F8BC2F52E927EA5008EE618 /* Promptly-WatchOS Watch App */, ); }; /* End PBXProject section */ @@ -151,6 +249,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 6F8BC2F42E927EA5008EE618 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -161,8 +266,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 6F8BC2F22E927EA5008EE618 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 6F8BC2FF2E927EA7008EE618 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6F8BC2F52E927EA5008EE618 /* Promptly-WatchOS Watch App */; + targetProxy = 6F8BC2FE2E927EA7008EE618 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 6F0425C32DF0BFE6002B2081 /* Debug */ = { isa = XCBuildConfiguration; @@ -287,12 +407,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Promptly/Promptly.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 8Y3J97SYZG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Promptly; + INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt; 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."; @@ -307,11 +427,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -331,12 +451,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Promptly/Promptly.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 8Y3J97SYZG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Promptly; + INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt; 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."; @@ -351,11 +471,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -368,6 +488,73 @@ }; name = Release; }; + 6F8BC3022E927EA7008EE618 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8Y3J97SYZG; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.urbanmechanicsltd.Promptly; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 6F8BC3032E927EA7008EE618 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8Y3J97SYZG; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.urbanmechanicsltd.Promptly; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -389,23 +576,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 6F8BC3012E927EA7008EE618 /* Build configuration list for PBXNativeTarget "Promptly-WatchOS Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6F8BC3022E927EA7008EE618 /* Debug */, + 6F8BC3032E927EA7008EE618 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 6F624EBD2DF370D700D17791 /* XCRemoteSwiftPackageReference "OpenAI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MacPaw/OpenAI"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.4.3; - }; - }; - 6F624EC02DF373FF00D17791 /* XCRemoteSwiftPackageReference "Yams" */ = { + 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jpsim/Yams"; + repositoryURL = "https://github.com/orchetect/MIDIKit"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.0.0; + minimumVersion = 0.10.5; }; }; 6FAE1C932E89E0A500D067BE /* XCRemoteSwiftPackageReference "mqtt-nio" */ = { @@ -419,15 +607,35 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 6F624EBE2DF370D700D17791 /* OpenAI */ = { + 6F8BC30A2E927FED008EE618 /* MQTTNIO */ = { + isa = XCSwiftPackageProductDependency; + package = 6FAE1C932E89E0A500D067BE /* XCRemoteSwiftPackageReference "mqtt-nio" */; + productName = MQTTNIO; + }; + 6F9232662EA2A9E500D929A7 /* MIDIKit */ = { + isa = XCSwiftPackageProductDependency; + package = 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */; + productName = MIDIKit; + }; + 6F9232682EA2A9E500D929A7 /* MIDIKitControlSurfaces */ = { + isa = XCSwiftPackageProductDependency; + package = 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */; + productName = MIDIKitControlSurfaces; + }; + 6F92326A2EA2A9E500D929A7 /* MIDIKitCore */ = { + isa = XCSwiftPackageProductDependency; + package = 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */; + productName = MIDIKitCore; + }; + 6F92326C2EA2A9E500D929A7 /* MIDIKitIO */ = { isa = XCSwiftPackageProductDependency; - package = 6F624EBD2DF370D700D17791 /* XCRemoteSwiftPackageReference "OpenAI" */; - productName = OpenAI; + package = 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */; + productName = MIDIKitIO; }; - 6F624EC12DF373FF00D17791 /* Yams */ = { + 6F92326E2EA2A9E500D929A7 /* MIDIKitSMF */ = { isa = XCSwiftPackageProductDependency; - package = 6F624EC02DF373FF00D17791 /* XCRemoteSwiftPackageReference "Yams" */; - productName = Yams; + package = 6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */; + productName = MIDIKitSMF; }; 6FAE1C942E89E0A500D067BE /* MQTTNIO */ = { isa = XCSwiftPackageProductDependency; diff --git a/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bdfe434..6df2dba 100644 --- a/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,22 +1,22 @@ { - "originHash" : "11bbdceb80b1d497c541689fd7138faf157ab6fbbdc493d740416bb2b81f32c9", + "originHash" : "bfa12cb89869b24734bffcae0a12d3f2ad26093cc5af5a79d12afe7e1c4ce68c", "pins" : [ { - "identity" : "mqtt-nio", + "identity" : "midikit", "kind" : "remoteSourceControl", - "location" : "https://github.com/sroebert/mqtt-nio.git", + "location" : "https://github.com/orchetect/MIDIKit", "state" : { - "revision" : "ad1f0bc339a6df89a28b419cdd452b0df584423d", - "version" : "2.8.1" + "revision" : "2e856b27af1ebdeb41017ec9e3ac0cd3ae1411a2", + "version" : "0.10.5" } }, { - "identity" : "openai", + "identity" : "mqtt-nio", "kind" : "remoteSourceControl", - "location" : "https://github.com/MacPaw/OpenAI", + "location" : "https://github.com/sroebert/mqtt-nio.git", "state" : { - "revision" : "9261cd39d55a718bcc360fbc29515a331cad5dbb", - "version" : "0.4.3" + "revision" : "ad1f0bc339a6df89a28b419cdd452b0df584423d", + "version" : "2.8.1" } }, { @@ -37,15 +37,6 @@ "version" : "1.2.1" } }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", - "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -82,15 +73,6 @@ "version" : "1.25.1" } }, - { - "identity" : "swift-openapi-runtime", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-runtime", - "state" : { - "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", - "version" : "1.8.2" - } - }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -101,12 +83,12 @@ } }, { - "identity" : "yams", + "identity" : "timecodekit", "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams", + "location" : "https://github.com/orchetect/TimecodeKit", "state" : { - "revision" : "9281f8c99aff4f4a55dce22ae29b1181c935caa5", - "version" : "6.0.0" + "revision" : "957fdaeda020396d150ee1afc7c5172791cb0ad5", + "version" : "2.3.4" } } ], diff --git a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate index 77c56e5..06d2da3 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.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist b/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist index d7ab3b3..ac39bd4 100644 --- a/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,6 +4,11 @@ SchemeUserState + Promptly-WatchOS Watch App.xcscheme_^#shared#^_ + + orderHint + 1 + Promptly.xcscheme_^#shared#^_ orderHint diff --git a/Promptly/.DS_Store b/Promptly/.DS_Store index b86cac2..4229bdb 100644 Binary files a/Promptly/.DS_Store and b/Promptly/.DS_Store differ diff --git a/Promptly/AI Script/AIScriptManager.swift b/Promptly/AI Script/AIScriptManager.swift deleted file mode 100644 index e31a068..0000000 --- a/Promptly/AI Script/AIScriptManager.swift +++ /dev/null @@ -1,589 +0,0 @@ -import Foundation -import PDFKit -import SwiftUI -import Yams - -class AIScriptManager: ObservableObject { - @Published var output: String = "" - @Published var isLoading: Bool = false - @Published var errorMessage: String? - @Published var streamingOutput: String = "" - @Published var processingStatus: String = "" - - private let apiKey: String - private let baseURL = "https://api.openai.com/v1" - - private let systemPrompt = """ - You are a script to yaml assistant. You follow the rules and format provided to you exactly. - - Rules: - - You must only return YAML content. No non-YAML content before, after, or anywhere. - - You only process scripts. You do not answer any questions, respond, or do anything with the contents of the script. - - You must not change any spelling mistakes in the script, wheater that be OCR issues, spelling, grammar. You directly follow the script. - - If musical notation is found, ignore it, and instead create line with content "Musical notation". - - SIMPLIFIED Expected YAML Structure (ONLY these fields): - ```yaml - id: PLACEHOLDER_ID - name: "Script Title" - dateAdded: "2025-06-09T12:00:00Z" - lines: - - id: PLACEHOLDER_ID - lineNumber: 1 - content: "Line content exactly as appears in script" - - id: PLACEHOLDER_ID - lineNumber: 2 - content: "Next line content" - sections: - - id: PLACEHOLDER_ID - title: "Act 1" - type: "act" - startLineNumber: 1 - endLineNumber: 25 - ``` - - Section Types Available: "act", "scene", "preset", "song_number", "custom" - - CRITICAL - ONLY output these fields: - - For lines: id, lineNumber, content (NO isMarked, markColor, notes) - - For sections: id, title, type, startLineNumber, endLineNumber (NO notes) - - We will add the missing fields programmatically - - Formatting: - - Use PLACEHOLDER_ID for all id fields - they will be replaced with actual UUIDs - - Extract logical sections (acts, scenes, songs) and mark their line ranges - - Each line must have a sequential lineNumber starting from 1 - - Preserve exact text content including typos and formatting - - Use current ISO 8601 timestamp for dateAdded - """ - - init(apiKey: String) { - self.apiKey = apiKey - } - - // MARK: - Main Processing Method - - func processWithBatchProcessing(pdfDocument: PDFDocument, url: URL) async -> Script? { - print("🚀 Starting batch processing for: \(url.lastPathComponent)") - - await MainActor.run { - isLoading = true - errorMessage = nil - output = "" - } - - do { - // Step 1: Extract text - await MainActor.run { output = "Extracting text from PDF..." } - let extractedText = extractTextFromPDF(pdfDocument) - print("📄 Extracted \(extractedText.count) characters from PDF") - - if extractedText.isEmpty { - await MainActor.run { - errorMessage = "No text could be extracted from PDF" - isLoading = false - } - return nil - } - - // Step 2: Process with batch approach - return try await processBatchedText(extractedText, fileName: url.lastPathComponent) - - } catch { - print("❌ Error in batch processing: \(error)") - await MainActor.run { - errorMessage = error.localizedDescription - isLoading = false - } - return nil - } - } - - // MARK: - Batch Processing Logic - - private func processBatchedText(_ text: String, fileName: String) async throws -> Script? { - // Create batches - each batch will have 3 mini-chunks - let miniChunks = createMiniChunks(text, maxChunkSize: 2000) - let batches = createBatches(from: miniChunks, batchSize: 3) - - print("📄 Created \(miniChunks.count) mini-chunks in \(batches.count) batches") - - await MainActor.run { - processingStatus = "Processing \(batches.count) batches..." - streamingOutput = "" - } - - var allLines: [SimplifiedScriptLine] = [] - var allSections: [SimplifiedScriptSection] = [] - var currentLineNumber = 1 - - for (batchIndex, batch) in batches.enumerated() { - print("🔄 Processing batch \(batchIndex + 1)/\(batches.count)") - - await MainActor.run { - processingStatus = "Processing batch \(batchIndex + 1) of \(batches.count)..." - output = "Processing batch \(batchIndex + 1) of \(batches.count)..." - } - - // Process entire batch in one API call - if let batchResult = try await processBatchWithRetry( - batch: batch, - batchIndex: batchIndex, - startingLineNumber: currentLineNumber, - fileName: fileName - ) { - print("🔍 Batch \(batchIndex + 1) returned \(batchResult.lines.count) lines") - - allLines.append(contentsOf: batchResult.lines) - - // Adjust section line numbers - let adjustedSections = batchResult.sections.map { section in - var adjusted = section - if let endLine = adjusted.endLineNumber { - adjusted.endLineNumber = endLine + currentLineNumber - 1 - } - adjusted.startLineNumber = (section.startLineNumber ?? 1) + currentLineNumber - 1 - return adjusted - } - allSections.append(contentsOf: adjustedSections) - - currentLineNumber += batchResult.lines.count - print("🔍 Next batch will start at line: \(currentLineNumber)") - } - - // Delay between batches - if batchIndex < batches.count - 1 { - await MainActor.run { - processingStatus = "Waiting 2 seconds before next batch..." - } - try await Task.sleep(nanoseconds: 2_000_000_000) - } - } - - // Convert to final script - let finalScript = createFinalScript( - lines: allLines, - sections: allSections, - fileName: fileName - ) - - print("✅ Batch processing complete: \(allLines.count) total lines") - - await MainActor.run { - processingStatus = "Complete! \(allLines.count) lines processed" - output = "Successfully processed \(batches.count) batches with \(allLines.count) total lines" - isLoading = false - } - - return finalScript.toSwiftDataModel() - } - - // MARK: - Chunking Logic - - private func createMiniChunks(_ text: String, maxChunkSize: Int) -> [String] { - var chunks: [String] = [] - let lines = text.components(separatedBy: .newlines) - var currentChunk = "" - - for line in lines { - let potentialChunk = currentChunk.isEmpty ? line : currentChunk + "\n" + line - - if potentialChunk.count > maxChunkSize && !currentChunk.isEmpty { - chunks.append(currentChunk) - currentChunk = line - } else { - currentChunk = potentialChunk - } - } - - if !currentChunk.isEmpty { - chunks.append(currentChunk) - } - - return chunks - } - - private func createBatches(from chunks: [String], batchSize: Int) -> [[String]] { - var batches: [[String]] = [] - - for i in stride(from: 0, to: chunks.count, by: batchSize) { - let endIndex = min(i + batchSize, chunks.count) - let batch = Array(chunks[i.. SimplifiedScript? { - var lastError: Error? - - for attempt in 1...maxRetries { - do { - return try await processBatch( - batch: batch, - batchIndex: batchIndex, - startingLineNumber: startingLineNumber, - fileName: fileName - ) - } catch { - lastError = error - print("❌ Batch \(batchIndex + 1) attempt \(attempt) failed: \(error)") - - if attempt < maxRetries { - let delay = Double(attempt * 3) // Longer delays for batches - print("⏳ Retrying batch \(batchIndex + 1) in \(delay) seconds...") - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - } - } - - throw lastError ?? APIError.runFailed - } - - private func processBatch(batch: [String], batchIndex: Int, startingLineNumber: Int, fileName: String) async throws -> SimplifiedScript? { - // Combine all chunks in batch with separators - let combinedText = batch.enumerated().map { index, chunk in - "=== CHUNK \(index + 1) ===\n\(chunk)" - }.joined(separator: "\n\n") - - let batchPrompt = """ - Convert this batch of text chunks to simplified YAML format. - - ⚠️ CRITICAL LINE NUMBERING: - - Start line numbering from \(startingLineNumber) - - Continue sequentially across ALL chunks in this batch - - Do NOT reset to 1 between chunks - - Example: if starting at 156, continue 156, 157, 158... across all chunks - - This batch contains \(batch.count) text chunks combined together. - Process them as one continuous document. - - BATCH PROCESSING RULES: - - Treat all chunks as one continuous script - - Maintain sequential line numbering throughout - - Combine sections that span multiple chunks - - Only include essential YAML fields - - \(systemPrompt) - - Combined Text Chunks: - \(combinedText) - """ - - let url = URL(string: "\(baseURL)/chat/completions")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - - let requestBody: [String: Any] = [ - "model": "gpt-4o-mini", - "messages": [ - [ - "role": "user", - "content": batchPrompt - ] - ], - "max_tokens": 8000, // Larger limit for batches - "temperature": 0.1 - ] - - request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) - - print("📤 Processing batch \(batchIndex + 1): \(combinedText.count) characters") - - // Enhanced URLSession config - let config = URLSessionConfiguration.default - config.waitsForConnectivity = true - config.timeoutIntervalForRequest = 120.0 - config.timeoutIntervalForResource = 600.0 - let session = URLSession(configuration: config) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw APIError.assistantCreationFailed - } - - print("📤 Batch \(batchIndex + 1) response status: \(httpResponse.statusCode)") - - if httpResponse.statusCode != 200 { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - print("❌ Batch \(batchIndex + 1) failed: \(responseString)") - throw APIError.assistantCreationFailed - } - - if let result = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = result["choices"] as? [[String: Any]], - let firstChoice = choices.first, - let message = firstChoice["message"] as? [String: Any], - let content = message["content"] as? String { - - print("✅ Batch \(batchIndex + 1) response: \(content.count) characters") - - // Better validation - if content.count < 50 || !content.contains("lines:") { - print("⚠️ Batch \(batchIndex + 1) response too short or malformed") - throw APIError.runFailed - } - - return try parseBatchYAML(content) - } - - throw APIError.messagesRetrievalFailed - } - - // MARK: - YAML Parsing for Batches - - private func parseBatchYAML(_ yamlText: String) throws -> SimplifiedScript { - var processedYAML = yamlText.trimmingCharacters(in: .whitespacesAndNewlines) - - // Remove markdown markers - if processedYAML.hasPrefix("```yaml") { - processedYAML = String(processedYAML.dropFirst(7)) - } else if processedYAML.hasPrefix("```") { - processedYAML = String(processedYAML.dropFirst(3)) - } - - if processedYAML.hasSuffix("```") { - processedYAML = String(processedYAML.dropLast(3)) - } - - processedYAML = processedYAML.trimmingCharacters(in: .whitespacesAndNewlines) - - // Clean escape sequences - processedYAML = processedYAML.replacingOccurrences(of: "\\;", with: ";") - processedYAML = processedYAML.replacingOccurrences(of: "\\:", with: ":") - processedYAML = processedYAML.replacingOccurrences(of: "\\!", with: "!") - processedYAML = processedYAML.replacingOccurrences(of: "\\?", with: "?") - - // Ensure sections field exists - if !processedYAML.contains("sections:") { - processedYAML += "\nsections: []" - } - - // Replace placeholder IDs - while processedYAML.contains("PLACEHOLDER_ID") { - processedYAML = processedYAML.replacingOccurrences( - of: "PLACEHOLDER_ID", - with: UUID().uuidString, - options: [], - range: processedYAML.range(of: "PLACEHOLDER_ID") - ) - } - - return try parseYAMLToModel(processedYAML, to: SimplifiedScript.self) - } - - // MARK: - Helper Methods - - private func extractTextFromPDF(_ document: PDFDocument) -> String { - guard let pageCount = document.pageCount as Int?, pageCount > 0 else { - return "" - } - - var fullText = "" - - for pageIndex in 0.. CodableScript { - let fullLines = lines.map { simplifiedLine in - CodableScriptLine( - id: simplifiedLine.id, - lineNumber: simplifiedLine.lineNumber, - content: simplifiedLine.content, - isMarked: false, - markColor: nil, - notes: "" - ) - } - - let fullSections = sections.map { simplifiedSection in - CodableScriptSection( - id: simplifiedSection.id, - title: simplifiedSection.title, - type: simplifiedSection.type, - startLineNumber: simplifiedSection.startLineNumber, - endLineNumber: simplifiedSection.endLineNumber, - notes: "" - ) - } - - return CodableScript( - id: UUID(), - name: fileName.replacingOccurrences(of: ".pdf", with: ""), - dateAdded: Date(), - lines: fullLines, - sections: fullSections - ) - } - - private func parseYAMLToModel(_ yamlText: String, to modelType: T.Type) throws -> T { - let yamlObject = try Yams.load(yaml: yamlText) - let jsonData = try JSONSerialization.data(withJSONObject: yamlObject as Any) - - let decoder = JSONDecoder() - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - if let date = formatter.date(from: dateString) { - return date - } - - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: dateString) { - return date - } - - return Date() - } - - return try decoder.decode(modelType, from: jsonData) - } -} - -// MARK: - Models (Same as before) - -struct SimplifiedScript: Codable { - let id: UUID - let name: String - let dateAdded: Date - let lines: [SimplifiedScriptLine] - let sections: [SimplifiedScriptSection] -} - -struct SimplifiedScriptLine: Codable { - let id: UUID - let lineNumber: Int - let content: String -} - -struct SimplifiedScriptSection: Codable { - let id: UUID - let title: String - let type: SectionType - var startLineNumber: Int? - var endLineNumber: Int? - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - id = try container.decode(UUID.self, forKey: .id) - title = try container.decode(String.self, forKey: .title) - startLineNumber = try container.decodeIfPresent(Int.self, forKey: .startLineNumber) - endLineNumber = try container.decodeIfPresent(Int.self, forKey: .endLineNumber) - - if let typeString = try? container.decode(String.self, forKey: .type) { - switch typeString.lowercased() { - case "act": type = .act - case "scene": type = .scene - case "preset", "set", "set_change": type = .preset - case "song_number", "song", "musical_number", "number": type = .songNumber - case "custom", "other", "misc": type = .custom - default: type = .custom - } - } else { - type = .custom - } - } -} - -struct CodableScript: Codable { - let id: UUID - let name: String - let dateAdded: Date - let lines: [CodableScriptLine] - let sections: [CodableScriptSection] - - func toSwiftDataModel() -> Script { - let script = Script(id: id, name: name, dateAdded: dateAdded, lines: []) - script.lines = lines.map { $0.toSwiftDataModel() } - script.sections = sections.map { $0.toSwiftDataModel() } - return script - } -} - -struct CodableScriptLine: Codable { - let id: UUID - let lineNumber: Int - let content: String - let isMarked: Bool - let markColor: String? - let notes: String - - func toSwiftDataModel() -> ScriptLine { - let line = ScriptLine(id: id, lineNumber: lineNumber, content: content) - line.isMarked = isMarked - line.markColor = markColor - line.notes = notes - return line - } -} - -struct CodableScriptSection: Codable { - let id: UUID - let title: String - let type: SectionType - let startLineNumber: Int - let endLineNumber: Int? - let notes: String - - init(id: UUID, title: String, type: SectionType, startLineNumber: Int?, endLineNumber: Int?, notes: String) { - self.id = id - self.title = title - self.type = type - self.startLineNumber = startLineNumber ?? 1 - self.endLineNumber = endLineNumber - self.notes = notes - } - - func toSwiftDataModel() -> ScriptSection { - let section = ScriptSection(id: id, title: title, type: type, startLineNumber: startLineNumber) - section.endLineNumber = endLineNumber - section.notes = notes - return section - } -} - -enum APIError: Error, LocalizedError { - case uploadFailed - case assistantCreationFailed - case threadCreationFailed - case messageCreationFailed - case runCreationFailed - case runRetrievalFailed - case runFailed - case runTimeout - case messagesRetrievalFailed - - var errorDescription: String? { - switch self { - case .uploadFailed: return "Failed to upload file" - case .assistantCreationFailed: return "Failed to create assistant" - case .threadCreationFailed: return "Failed to create thread" - case .messageCreationFailed: return "Failed to create message" - case .runCreationFailed: return "Failed to create run" - case .runRetrievalFailed: return "Failed to retrieve run status" - case .runFailed: return "Run failed or was cancelled" - case .runTimeout: return "Run timed out" - case .messagesRetrievalFailed: return "Failed to retrieve messages" - } - } -} diff --git a/Promptly/Assets.xcassets/.DS_Store b/Promptly/Assets.xcassets/.DS_Store new file mode 100644 index 0000000..61ab1c0 Binary files /dev/null and b/Promptly/Assets.xcassets/.DS_Store differ diff --git a/Promptly/Assets.xcassets/AppIcon.appiconset/.DS_Store b/Promptly/Assets.xcassets/AppIcon.appiconset/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/Promptly/Assets.xcassets/AppIcon.appiconset/.DS_Store differ diff --git a/Promptly/Helpers/MIDIHelpers.swift b/Promptly/Helpers/MIDIHelpers.swift new file mode 100644 index 0000000..628e16a --- /dev/null +++ b/Promptly/Helpers/MIDIHelpers.swift @@ -0,0 +1,139 @@ +// +// MIDIHelper.swift +// MIDIKit • https://github.com/orchetect/MIDIKit +// © 2021-2025 Steffan Andrews • Licensed under MIT License +// + +import MIDIKitIO +import SwiftUI + +/// Receiving MIDI happens on an asynchronous background thread. That means it cannot update +/// SwiftUI view state directly. Therefore, we need a helper class marked with `@Observable` +/// which contains properties that SwiftUI can use to update views. +@Observable final class MIDIHelper { + private weak var midiManager: ObservableMIDIManager? + + // MIDI Action Types (matching your existing remote actions) + enum RemoteAction: String, CaseIterable { + case nextLine = "Next Line" + case previousLine = "Previous Line" + case goCue = "Go Cue" + case none = "None" + } + + // User-configurable mapping: Program Change number -> Action + var programChangeMapping: [Int: RemoteAction] = [:] + + // Callback to execute remote functions (same pattern as bluetoothManager) + var onButtonPress: ((String) -> Void)? + + public init() { } + + public func setup(midiManager: ObservableMIDIManager) { + self.midiManager = midiManager + + do { + print("Starting MIDI services.") + try midiManager.start() + } catch { + print("Error starting MIDI services:", error.localizedDescription) + } + + setupConnections() + } + + private func setupConnections() { + guard let midiManager else { return } + + do { + try midiManager.addInputConnection( + to: .allOutputs, + tag: "Listener", + filter: .owned(), + receiver: .events { [weak self] events,_,_ in + self?.handleMIDIEvents(events) + } + ) + } catch { + print("Error setting up MIDI connection:", error.localizedDescription) + } + + // Keep your broadcaster + do { + try midiManager.addOutputConnection( + to: .allInputs, + tag: "Broadcaster", + filter: .owned() + ) + } catch { + print("Error setting up broadcaster connection:", error.localizedDescription) + } + } + + private func handleMIDIEvents(_ events: [MIDIEvent]) { + for event in events { + switch event { + case .programChange(let programChange): + handleProgramChange(program: programChange.program, channel: programChange.channel) + default: + // Log other events for debugging + print("MIDI Event: \(event)") + } + } + } + + private func handleProgramChange(program: UInt7, channel: UInt4) { + let programInt = Int(program) + + // Only handle program changes 0-32 + guard programInt <= 32 else { + print("Program change \(programInt) out of range (0-32)") + return + } + + print("Received PC \(programInt) on channel \(channel)") + + // Check if user has mapped this program change to an action + guard let action = programChangeMapping[programInt], + action != .none else { + print("No action mapped for PC \(programInt)") + return + } + + print("Executing MIDI action: \(action.rawValue)") + + // Convert to the same format as your Bluetooth remote + let buttonValue: String + switch action { + case .previousLine: + buttonValue = "0" + case .nextLine: + buttonValue = "1" + case .goCue: + buttonValue = "2" + case .none: + return + } + + // Execute on main thread using the same callback pattern + DispatchQueue.main.async { + self.onButtonPress?(buttonValue) + } + } + + // Configuration methods + func mapProgramChange(_ program: Int, to action: RemoteAction) { + guard program >= 0 && program <= 32 else { return } + programChangeMapping[program] = action + print("Mapped PC \(program) to \(action.rawValue)") + } + + func clearMapping(for program: Int) { + programChangeMapping[program] = .none + } + + func sendTestMIDIEvent() { + let conn = midiManager?.managedOutputConnections["Broadcaster"] + try? conn?.send(event: .cc(.expression, value: .midi1(64), channel: 0)) + } +} diff --git a/Promptly/MQTT/MQTTManager.swift b/Promptly/MQTT/MQTTManager.swift index 7c58439..8e77b7f 100644 --- a/Promptly/MQTT/MQTTManager.swift +++ b/Promptly/MQTT/MQTTManager.swift @@ -8,6 +8,7 @@ import Foundation import MQTTNIO import Combine +internal import NIOCore class MQTTManager: ObservableObject { private var client: MQTTClient? diff --git a/Promptly/Models/Performance.swift b/Promptly/Models/Performance.swift index dbc397f..65f8868 100644 --- a/Promptly/Models/Performance.swift +++ b/Promptly/Models/Performance.swift @@ -7,6 +7,7 @@ import Foundation import SwiftData +import SwiftUI @Model class Performance: Identifiable { @@ -354,3 +355,68 @@ class ReportShowStop: Identifiable { self.actNumber = actNumber } } + + +struct CallLogEntry: Identifiable { + let id = UUID() + let timestamp: Date + let message: String + let type: CallType + + enum CallType { + case call, action, emergency, note + + var color: Color { + switch self { + case .call: return .blue + case .action: return .green + case .emergency: return .red + case .note: return .orange + } + } + } +} + +extension PerformanceState { + var displayName: String { + switch self { + case .preShow: return "Pre-Show" + case .houseOpen: return "House Open" + case .clearance: return "Stage Clear" + case .inProgress(let act): return "Act \(act) Running" + case .interval(let interval): return "Interval \(interval)" + case .completed: return "Show Complete" + case .stopped: return "Show Stopped" + } + } + + var color: Color { + switch self { + case .preShow: return .gray + case .houseOpen: return .blue + case .clearance: return .orange + case .inProgress: return .green + case .interval: return .purple + case .completed: return .green + case .stopped: return .red + } + } + + var actNumber: Int? { + switch self { + case .inProgress(let actNumber): return actNumber + default: return nil + } + } +} + +extension CueType { + var isStandby: Bool { + switch self { + case .lightingStandby, .soundStandby, .flyStandby, .automationStandby: + return true + default: + return false + } + } +} diff --git a/Promptly/Models/Script.swift b/Promptly/Models/Script.swift index 6ee6b42..ca9f5d3 100644 --- a/Promptly/Models/Script.swift +++ b/Promptly/Models/Script.swift @@ -7,6 +7,7 @@ import Foundation import SwiftData +import SwiftUI @Model class Script: Identifiable { @@ -408,3 +409,56 @@ extension Cue { self.alertSound = dict["alertSound"] as? String } } + +extension UIColor { + convenience init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + red: CGFloat(r) / 255, + green: CGFloat(g) / 255, + blue: CGFloat(b) / 255, + alpha: CGFloat(a) / 255 + ) + } +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/Promptly/PromptlyApp.swift b/Promptly/PromptlyApp.swift index 0c326fc..d38c69d 100644 --- a/Promptly/PromptlyApp.swift +++ b/Promptly/PromptlyApp.swift @@ -7,19 +7,35 @@ import SwiftUI import SwiftData +import MIDIKitIO + @main struct PromptlyApp: App { + @State var midiManager = ObservableMIDIManager( + clientName: "DSMPromptMIDIManager", + model: "DSMPrompt", + manufacturer: "UrbanMechanicsLTD" + ) + + @State var midiHelper = MIDIHelper() + init() { + #if !os(macOS) UIScrollView.appearance().scrollsToTop = false + #endif if UserDefaults.standard.string(forKey: "deviceUUID") == nil { UserDefaults.standard.set(UUID().uuidString, forKey: "deviceUUID") } + + midiHelper.setup(midiManager: midiManager) } var body: some Scene { WindowGroup { HomeScreenView() + .environment(midiManager) + .environment(midiHelper) } .modelContainer(for: [Show.self, PerformanceReport.self]) } diff --git a/Promptly/Views/.DS_Store b/Promptly/Views/.DS_Store index 81f7e80..151c6b3 100644 Binary files a/Promptly/Views/.DS_Store and b/Promptly/Views/.DS_Store differ diff --git a/Promptly/Views/Export/PerformanceReportView.swift b/Promptly/Views/Export/PerformanceReportView.swift index 4006d1e..2336898 100644 --- a/Promptly/Views/Export/PerformanceReportView.swift +++ b/Promptly/Views/Export/PerformanceReportView.swift @@ -58,10 +58,6 @@ struct PerformanceReportView: View { Button("Export PDF", systemImage: "square.and.arrow.up") { generatePDFSheetIsPresent = true } - - Button("Share Report", systemImage: "square.and.arrow.up") { - showingShareSheet = true - } } label: { Image(systemName: "ellipsis.circle") } diff --git a/Promptly/Views/Export/ScriptExportView.swift b/Promptly/Views/Export/ScriptExportView.swift index 1eee865..73ba087 100644 --- a/Promptly/Views/Export/ScriptExportView.swift +++ b/Promptly/Views/Export/ScriptExportView.swift @@ -346,31 +346,7 @@ struct ScriptPDFExporterView: View { } // MARK: - UIColor extension for hex colors -extension UIColor { - convenience init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: // RGB (12-bit) - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RGB (24-bit) - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // ARGB (32-bit) - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (1, 1, 1, 0) - } - - self.init( - red: CGFloat(r) / 255, - green: CGFloat(g) / 255, - blue: CGFloat(b) / 255, - alpha: CGFloat(a) / 255 - ) - } -} + // MARK: - Error types enum ExportError: Error, LocalizedError { diff --git a/Promptly/Views/Home Screen/HomeScreenView.swift b/Promptly/Views/Home Screen/HomeScreenView.swift index c7c36b2..278bf86 100644 --- a/Promptly/Views/Home Screen/HomeScreenView.swift +++ b/Promptly/Views/Home Screen/HomeScreenView.swift @@ -7,6 +7,8 @@ import SwiftUI import SwiftData +import MIDIKitIO + struct HomeScreenView: View { @Query var shows: [Show] = [] @@ -19,6 +21,8 @@ struct HomeScreenView: View { @State var availableShows: [String: String] = [:] @StateObject private var mqttManager = MQTTManager() + @Environment(ObservableMIDIManager.self) private var midiManager + @Environment(MIDIHelper.self) private var midiHelper var body: some View { NavigationStack { @@ -100,6 +104,14 @@ struct HomeScreenView: View { Label("Network Settings", systemImage: "network") } + NavigationLink( + destination: BluetoothMIDIView() + .navigationTitle("Remote Peripheral Config") + .navigationBarTitleDisplayMode(.inline) + ) { + Label("MIDI", systemImage: "av.remote") + } + Button { self.addShow = true } label: { diff --git a/Promptly/Views/MIDI/ConnMIDIBle.swift b/Promptly/Views/MIDI/ConnMIDIBle.swift new file mode 100644 index 0000000..eee1a2a --- /dev/null +++ b/Promptly/Views/MIDI/ConnMIDIBle.swift @@ -0,0 +1,45 @@ +// +// BluetoothMIDIView.swift +// MIDIKit • https://github.com/orchetect/MIDIKit +// © 2021-2025 Steffan Andrews • Licensed under MIT License +// + +#if os(iOS) + +import CoreAudioKit +import SwiftUI +import UIKit + +struct BluetoothMIDIView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> BTMIDICentralViewController { + BTMIDICentralViewController() + } + + func updateUIViewController( + _ uiViewController: BTMIDICentralViewController, + context: Context + ) { } + + typealias UIViewControllerType = BTMIDICentralViewController +} + +class BTMIDICentralViewController: CABTMIDICentralViewController { + var uiViewController: UIViewController? + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneAction) + ) + } + + @objc + public func doneAction() { + uiViewController?.dismiss(animated: true, completion: nil) + } +} + +#endif diff --git a/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift b/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift index ea3318f..fe8d685 100644 --- a/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift +++ b/Promptly/Views/Multiplayer/MultiPlayerShowDetail.swift @@ -22,89 +22,108 @@ struct MultiPlayerShowDetail: View { @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) - } + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if let title = title { + Text(title) + .font(.largeTitle) + .bold() } - if let scriptName = scriptName { - HStack { - Text("Script:") - .foregroundColor(.secondary) - Text(scriptName) + VStack(alignment: .leading, spacing: 12) { + if let location = location { + HStack { + Text("Location:") + .foregroundColor(.secondary) + Text(location) + } } - } - - if let status = status { - HStack { - Text("Status:") - .foregroundColor(.secondary) - Text(status) - .foregroundColor(status == "active" ? .green : .orange) + + 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)) + } } } - 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) } } - - Spacer() - - Button(action: { - if receivedScript != nil { - showingShow = true - } else { - isLoading = true - fetchNetwork() { script in - if let script = script { - self.receivedScript = script - } - } + .padding() +#if !os(macOS) + .fullScreenCover(isPresented: $isLoading) { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) } - }) { - 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) + } } - } - .fullScreenCover(isPresented: $showingShow) { - if let script = self.receivedScript { - SpectatorPerformanceView(showId: UUID(uuidString: self.showID)!, script: script, mqttManager: self.mqttManager) +#else + .sheet(isPresented: $isLoading) { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + .sheet(isPresented: $showingShow) { + if let script = self.receivedScript { + SpectatorPerformanceView(showId: UUID(uuidString: self.showID)!, script: script, mqttManager: self.mqttManager) + } + } +#endif + .onAppear { + loadShow() } - } - .onAppear { - loadShow() } } diff --git a/Promptly/Views/Performance Mode/CueTagView.swift b/Promptly/Views/Performance Mode/CueTagView.swift new file mode 100644 index 0000000..6b9dae9 --- /dev/null +++ b/Promptly/Views/Performance Mode/CueTagView.swift @@ -0,0 +1,36 @@ +// +// CueTagView.swift +// Promptly +// +// Created by Sasha Bagrov on 05/10/2025. +// + +import SwiftUI + +struct CueTagView: View { + let cue: Cue + let isCalled: Bool + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(Color(hex: cue.type.color)) + .frame(width: 8, height: 8) + + Text(cue.label) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(isCalled ? .secondary : .primary) + .strikethrough(isCalled) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(hex: cue.type.color).opacity(isCalled ? 0.1 : 0.2)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isCalled ? Color.gray : Color(hex: cue.type.color), lineWidth: 2) + ) + } +} diff --git a/Promptly/Views/Performance Mode/DSMScriptLineView.swift b/Promptly/Views/Performance Mode/DSMScriptLineView.swift new file mode 100644 index 0000000..f29adf3 --- /dev/null +++ b/Promptly/Views/Performance Mode/DSMScriptLineView.swift @@ -0,0 +1,92 @@ +// +// DSMScriptLineView.swift +// Promptly +// +// Created by Sasha Bagrov on 05/10/2025. +// + +import SwiftUI + +struct DSMScriptLineView: View { + let line: ScriptLine + let isCurrent: Bool + let onLineTap: () -> Void + let calledCues: Set + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button(action: onLineTap) { + HStack(alignment: .top, spacing: 12) { + Text(verbatim: "\(line.lineNumber)") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(isCurrent ? .black : .secondary) + .frame(width: 30, alignment: .trailing) + + VStack(alignment: .leading, spacing: 6) { + scriptLineWithCues + scriptContentWithCueArrows + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isCurrent ? Color.yellow : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isCurrent ? Color.orange : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + private var scriptLineWithCues: some View { + VStack(alignment: .leading, spacing: 4) { + if !line.cues.isEmpty { + HStack(spacing: 4) { + ForEach(line.cues) { cue in + CueTagView(cue: cue, isCalled: calledCues.contains(cue.id)) + } + Spacer() + } + } + } + } + + private var scriptContentWithCueArrows: some View { + let cuesByIndex = Dictionary(grouping: line.cues) { $0.position.elementIndex } + let words = line.content.split(separator: " ", omittingEmptySubsequences: false) + + return Text(buildLineWithCues(words: words, cuesByIndex: cuesByIndex)) + .font(.body) + .foregroundColor(isCurrent ? .black : .primary) + } + + private func buildLineWithCues(words: [Substring], cuesByIndex: [Int: [Cue]]) -> AttributedString { + var result = AttributedString() + + for (i, word) in words.enumerated() { + if let cues = cuesByIndex[i] { + for cue in cues { + var label = AttributedString("⬇︎ \(cue.label) ") + + label.foregroundColor = calledCues.contains(cue.id) ? .secondary : Color(hex: cue.type.color) + label.inlinePresentationIntent = .emphasized + + if calledCues.contains(cue.id) { + label.strikethroughStyle = Text.LineStyle.single + } + result += label + } + } + + var wordAttr = AttributedString(word + " ") + result += wordAttr + } + + return result + } +} diff --git a/Promptly/Views/Performance Mode/LivePerforemanceView.swift b/Promptly/Views/Performance Mode/LivePerforemanceView.swift index 72943bb..831c8a7 100644 --- a/Promptly/Views/Performance Mode/LivePerforemanceView.swift +++ b/Promptly/Views/Performance Mode/LivePerforemanceView.swift @@ -4,6 +4,7 @@ import Foundation import Network import Darwin import Combine +import MIDIKitIO struct DSMPerformanceView: View { @Environment(\.modelContext) private var modelContext @@ -16,16 +17,23 @@ struct DSMPerformanceView: View { @State private var showingEndConfirmation = false @State private var showingStopAlert = false @State private var stopReason = "" + @State private var goToLine = "" + @State private var showingGoToLineAlert = false @State private var callsLog: [CallLogEntry] = [] @State private var currentLineNumber = 1 + @State private var scrollToLineNumber: Int? = 1 @State private var allCues: [Cue] = [] @State private var calledCues: Set = [] @State private var hiddenCues: Set = [] @State private var cueHideTimers: [UUID: Timer] = [:] @State private var showingDetails = false @State private var showingSettings = false + @State private var showingResetScrollButton = false + @State private var showingRemoteSettingsAlert: Bool = false @State private var showingBluetoothSettings = false + @State private var showingGoToSectionSheet = false @State private var keepDisplayAwake = true + @State private var showAlertWhenEndingShowWithPause = false @State private var scrollToChangesActiveLine = false @State private var currentTime = Date() @State private var stopTime: Date? @@ -33,22 +41,22 @@ struct DSMPerformanceView: View { @State private var showingCueAlert = false @State private var cueAlertTimer: Timer? @State private var uuidOfShow: String = "" + @State private var showingMIDISettings = false @FocusState private var isViewFocused: Bool @StateObject private var bluetoothManager = PromptlyBluetoothManager() @StateObject private var mqttManager = MQTTManager() @StateObject private var jsonServer = JSONServer(port: 8080) + @Environment(ObservableMIDIManager.self) private var midiManager + @Environment(MIDIHelper.self) private var midiHelper + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private var script: Script? { performance.show?.script } - // private var sortedLinesCache: [ScriptLine] { - // script?.lines.sorted { $0.lineNumber < $1.lineNumber } ?? [] - // } - @State private var sortedLinesCache: [ScriptLine] = [] @State private var sortedCuesCache: [Cue] = [] @@ -126,8 +134,49 @@ struct DSMPerformanceView: View { var body: some View { mainContentView - .applyDSMModifiers( + .sheet(isPresented: $showingMIDISettings) { + MIDIConfigurationView(midiHelper: midiHelper) + } + .alert("Which type?", isPresented: self.$showingRemoteSettingsAlert) { + Button("Cancel", role: .cancel) {} + Button("Bluetooth") { + self.showingBluetoothSettings = true + } + Button("MIDI") { + self.showingMIDISettings = true + } + } + .alert("Go To Line (set active)", isPresented: self.$showingGoToLineAlert) { + TextField("Line", text: self.$goToLine).keyboardType(.numberPad) + Button("Cancel", role: .cancel) { } + Button("Go To", role: .destructive) { + self.moveToLine(Int(self.goToLine) ?? 0) + self.goToLine = "" + } + } + .applyDSMCore( isViewFocused: $isViewFocused, + onAppear: setupView, + onDisappear: cleanupView, + timer: timer, + currentTime: $currentTime + ) + .applyDSMKeyboard( + sortedLinesCache: sortedLinesCache, + currentLineNumber: currentLineNumber, + onLineMove: moveToLine + ) + .applyDSMObservers( + allCues: allCues, + hiddenCues: hiddenCues, + sortedLinesCache: sortedLinesCache, + script: script, + currentState: $currentState, + onCacheUpdate: updateCuesCache, + onScriptChange: handleScriptChange, + onStateChange: handleStateChange + ) + .applyDSMAlerts( showingStopAlert: $showingStopAlert, showingEndConfirmation: $showingEndConfirmation, showingBluetoothSettings: $showingBluetoothSettings, @@ -145,9 +194,8 @@ struct DSMPerformanceView: View { isShowRunning: $isShowRunning, canMakeQuickCalls: canMakeQuickCalls, bluetoothManager: bluetoothManager, - onAppear: setupView, - onDisappear: cleanupView, - onStateChange: handleStateChange, + showAlertWhenEndingShowWithPause: $showAlertWhenEndingShowWithPause, + endPerformanceWithoutEnd: endPerformanceWithoutEnd, onStartShow: startShow, onStopShow: { showingStopAlert = true }, onEndShow: { showingEndConfirmation = true }, @@ -155,16 +203,8 @@ struct DSMPerformanceView: View { 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 + goToLine: goToLine, + showingGoToSectionSheet: $showingGoToSectionSheet ) } @@ -216,6 +256,7 @@ struct DSMPerformanceView: View { private func setupView() { isViewFocused = true + if keepDisplayAwake { UIApplication.shared.isIdleTimerDisabled = true } @@ -244,25 +285,35 @@ struct DSMPerformanceView: View { ) let showUUID = show.id.uuidString - print("🚀 Setting uuidOfShow to: '\(showUUID)'") + print("🎭 Setting uuidOfShow to: '\(showUUID)'") uuidOfShow = showUUID - print("🚀 Sending initial line with UUID: '\(showUUID)'") + print("🎭 Sending initial line with UUID: '\(showUUID)'") mqttManager.sendData(to: "shows/\(showUUID)/line", message: "1") } + // Existing Bluetooth setup 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() + handleRemoteButtonPress(value) + } + + // NEW: MIDI setup using the same handler + midiHelper.onButtonPress = { value in + handleRemoteButtonPress(value) + } + } + + private func handleRemoteButtonPress(_ value: String) { + 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() } } @@ -352,23 +403,41 @@ struct DSMPerformanceView: View { } HStack(spacing: 8) { - Button(action: { - showingBluetoothSettings = true - }) { - HStack(spacing: 4) { - Image(systemName: bluetoothManager.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") - .foregroundColor(bluetoothManager.isConnected ? .blue : .secondary) - if bluetoothManager.isConnected { - Text("Remote") - .font(.caption) + if self.showingResetScrollButton { + Button { + self.scrollToLineNumber = self.currentLineNumber + self.showingResetScrollButton = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.scrollToLineNumber = nil } + } label: { + Image(systemName: "chevron.up") } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) - Button("Settings") { - showingSettings = true - } + Menu(content: { + Button(action: { + showingRemoteSettingsAlert = true + }) { + Label( + "Remotes", + systemImage: "antenna.radiowaves.left.and.right" + ) + } + + Button { + showingSettings = true + } label: { + Label( + "Settings", + systemImage: "gear" + ) + } + }, label: { + Image(systemName: "ellipsis.circle") + }) .buttonStyle(.bordered) Button("Details") { @@ -376,8 +445,9 @@ struct DSMPerformanceView: View { } .buttonStyle(.bordered) + // Stop / start button if isShowRunning { - Button("STOP", role: .destructive) { + Button("E STOP", role: .destructive) { showingStopAlert = true } .buttonStyle(.borderedProminent) @@ -419,7 +489,7 @@ struct DSMPerformanceView: View { HStack(spacing: 8) { Button("Go to Line") { - + self.showingGoToLineAlert = true } .font(.caption) .padding(.horizontal, 8) @@ -508,7 +578,6 @@ struct DSMPerformanceView: View { private var scriptContentView: some View { let showUUID = self.uuidOfShow - print("🔍 scriptContentView rendering - uuidOfShow: '\(showUUID)'") return ScrollViewReader { proxy in ScrollView { @@ -518,10 +587,6 @@ 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/\(showUUID)/line", message: "\(line.lineNumber)") }, @@ -537,6 +602,24 @@ struct DSMPerformanceView: View { .onChange(of: currentLineNumber) { _, newValue in proxy.scrollTo("line-\(newValue)", anchor: .center) } + .onChange(of: scrollToLineNumber) { _, newValue in + print("got value (new) to scroll to: \(String(describing: newValue))") + if let value = newValue { + proxy.scrollTo("line-\(value)", anchor: .center) + print("scrolled") + if value != currentLineNumber { + self.showingResetScrollButton = true + print("button reset") + } + } + } + .onScrollPhaseChange { a, b in + if a.isScrolling { + withAnimation(.interactiveSpring) { + self.showingResetScrollButton = true + } + } + } } } @@ -809,90 +892,6 @@ struct DSMSectionBadge: View { } } -struct DSMScriptLineView: View { - let line: ScriptLine - let isCurrent: Bool - let onLineTap: () -> Void - let calledCues: Set - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: onLineTap) { - HStack(alignment: .top, spacing: 12) { - Text("\(line.lineNumber)") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(isCurrent ? .black : .secondary) - .frame(width: 30, alignment: .trailing) - - VStack(alignment: .leading, spacing: 6) { - scriptLineWithCues - scriptContentWithCueArrows - } - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isCurrent ? Color.yellow : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(isCurrent ? Color.orange : Color.clear, lineWidth: 2) - ) - } - .buttonStyle(PlainButtonStyle()) - } - } - - private var scriptLineWithCues: some View { - VStack(alignment: .leading, spacing: 4) { - if !line.cues.isEmpty { - HStack(spacing: 4) { - ForEach(line.cues) { cue in - CueTagView(cue: cue, isCalled: calledCues.contains(cue.id)) - } - Spacer() - } - } - } - } - - private var scriptContentWithCueArrows: some View { - let cuesByIndex = Dictionary(grouping: line.cues) { $0.position.elementIndex } - let words = line.content.split(separator: " ", omittingEmptySubsequences: false) - - return Text(buildLineWithCues(words: words, cuesByIndex: cuesByIndex)) - .font(.body) - .foregroundColor(isCurrent ? .black : .primary) - } - - private func buildLineWithCues(words: [Substring], cuesByIndex: [Int: [Cue]]) -> AttributedString { - var result = AttributedString() - - for (i, word) in words.enumerated() { - if let cues = cuesByIndex[i] { - for cue in cues { - var label = AttributedString("⬇︎ \(cue.label) ") - - label.foregroundColor = calledCues.contains(cue.id) ? .secondary : Color(hex: cue.type.color) - label.inlinePresentationIntent = .emphasized - - if calledCues.contains(cue.id) { - label.strikethroughStyle = Text.LineStyle.single - } - result += label - } - } - - var wordAttr = AttributedString(word + " ") - result += wordAttr - } - - return result - } -} - struct DSMFlowLayout: Layout { var spacing: CGFloat = 8 @@ -945,33 +944,7 @@ struct DSMFlowResult { } } -struct CueTagView: View { - let cue: Cue - let isCalled: Bool - - var body: some View { - HStack(spacing: 4) { - Circle() - .fill(Color(hex: cue.type.color)) - .frame(width: 8, height: 8) - - Text(cue.label) - .font(.headline) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundColor(isCalled ? .secondary : .primary) - .strikethrough(isCalled) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(hex: cue.type.color).opacity(isCalled ? 0.1 : 0.2)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isCalled ? Color.gray : Color(hex: cue.type.color), lineWidth: 2) - ) - } -} + struct DSMSettingsView: View { @Binding var keepDisplayAwake: Bool @@ -1103,6 +1076,7 @@ struct DSMDetailsView: View { @Binding var callsLog: [CallLogEntry] @Binding var currentState: PerformanceState @Binding var isShowRunning: Bool + @Binding var showAlertWhenEndingShowWithPause: Bool let canMakeQuickCalls: Bool let onStartShow: () -> Void let onStopShow: () -> Void @@ -1355,6 +1329,12 @@ struct DSMDetailsView: View { .navigationTitle("Performance Details") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Pause and End") { + self.showAlertWhenEndingShowWithPause = true + } + } + ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() @@ -1516,6 +1496,17 @@ extension DSMPerformanceView { // Dismiss the DSM view after a brief delay to show the completion state DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.mqttManager.removeShow(id: self.uuidOfShow) + self.jsonServer.stop() + dismiss() + } + } + + private func endPerformanceWithoutEnd() { + isShowRunning = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.mqttManager.removeShow(id: self.uuidOfShow) + self.jsonServer.stop() dismiss() } } @@ -1572,6 +1563,8 @@ extension DSMPerformanceView { if let line = sortedLinesCache.first(where: { $0.id == cue.lineId }) { if scrollToChangesActiveLine { currentLineNumber = line.lineNumber + } else { + scrollToLineNumber = line.lineNumber } } } @@ -1693,76 +1686,73 @@ extension DSMPerformanceView { let entry = CallLogEntry(timestamp: Date(), message: message, type: type) callsLog.append(entry) } -} - -struct CallLogEntry: Identifiable { - let id = UUID() - let timestamp: Date - let message: String - let type: CallType - enum CallType { - case call, action, emergency, note - - var color: Color { - switch self { - case .call: return .blue - case .action: return .green - case .emergency: return .red - case .note: return .orange - } - } + func goToLine(_ lineNumber: Int) { + moveToLine(lineNumber) } } -extension PerformanceState { - var displayName: String { - switch self { - case .preShow: return "Pre-Show" - case .houseOpen: return "House Open" - case .clearance: return "Stage Clear" - case .inProgress(let act): return "Act \(act) Running" - case .interval(let interval): return "Interval \(interval)" - case .completed: return "Show Complete" - case .stopped: return "Show Stopped" - } +extension View { + // Core navigation and appearance + func applyDSMCore( + isViewFocused: FocusState.Binding, + onAppear: @escaping () -> Void, + onDisappear: @escaping () -> Void, + timer: Publishers.Autoconnect, + currentTime: Binding + ) -> some View { + self + .navigationBarHidden(true) + .preferredColorScheme(.dark) + .focusable() + .focused(isViewFocused) + .onAppear(perform: onAppear) + .onDisappear(perform: onDisappear) + .onReceive(timer) { _ in currentTime.wrappedValue = Date() } } - var color: Color { - switch self { - case .preShow: return .gray - case .houseOpen: return .blue - case .clearance: return .orange - case .inProgress: return .green - case .interval: return .purple - case .completed: return .green - case .stopped: return .red - } + // Keyboard navigation + func applyDSMKeyboard( + sortedLinesCache: [ScriptLine], + currentLineNumber: Int, + onLineMove: @escaping (Int) -> Void + ) -> some View { + self + .onKeyPress(.downArrow) { + let next = sortedLinesCache.first(where: { $0.lineNumber > currentLineNumber })?.lineNumber + let fallback = sortedLinesCache.last?.lineNumber ?? 1 + withAnimation(.easeOut(duration: 0.1)) { onLineMove(next ?? fallback) } + return .handled + } + .onKeyPress(.upArrow) { + let prev = sortedLinesCache.last(where: { $0.lineNumber < currentLineNumber })?.lineNumber + let fallback = sortedLinesCache.first?.lineNumber ?? 1 + withAnimation(.easeOut(duration: 0.1)) { onLineMove(prev ?? fallback) } + return .handled + } } - var actNumber: Int? { - switch self { - case .inProgress(let actNumber): return actNumber - default: return nil - } - } -} - -extension CueType { - var isStandby: Bool { - switch self { - case .lightingStandby, .soundStandby, .flyStandby, .automationStandby: - return true - default: - return false - } + // Data change observers + func applyDSMObservers( + allCues: [Cue], + hiddenCues: Set, + sortedLinesCache: [ScriptLine], + script: Script?, + currentState: Binding, + onCacheUpdate: @escaping () -> Void, + onScriptChange: @escaping () -> Void, + onStateChange: @escaping () -> Void + ) -> some View { + self + .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() } } -} - - -extension View { - func applyDSMModifiers( - isViewFocused: FocusState.Binding, + + // Alerts and modals + func applyDSMAlerts( showingStopAlert: Binding, showingEndConfirmation: Binding, showingBluetoothSettings: Binding, @@ -1780,9 +1770,8 @@ extension View { isShowRunning: Binding, canMakeQuickCalls: Bool, bluetoothManager: PromptlyBluetoothManager, - onAppear: @escaping () -> Void, - onDisappear: @escaping () -> Void, - onStateChange: @escaping () -> Void, + showAlertWhenEndingShowWithPause: Binding, + endPerformanceWithoutEnd: @escaping () -> Void, onStartShow: @escaping () -> Void, onStopShow: @escaping () -> Void, onEndShow: @escaping () -> Void, @@ -1790,44 +1779,10 @@ extension View { 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? + goToLine: @escaping (Int) -> Void, + showingGoToSectionSheet: Binding ) -> 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) { } @@ -1841,10 +1796,7 @@ extension View { PromptlyBluetoothSettingsView(bluetoothManager: bluetoothManager) } .sheet(isPresented: showingSettings) { - DSMSettingsView( - keepDisplayAwake: keepDisplayAwake, - scrollToChangesActiveLine: scrollToChangesActiveLine - ) + DSMSettingsView(keepDisplayAwake: keepDisplayAwake, scrollToChangesActiveLine: scrollToChangesActiveLine) } .sheet(isPresented: showingDetails) { DSMDetailsView( @@ -1855,6 +1807,7 @@ extension View { callsLog: callsLog, currentState: currentState, isShowRunning: isShowRunning, + showAlertWhenEndingShowWithPause: showAlertWhenEndingShowWithPause, canMakeQuickCalls: canMakeQuickCalls, onStartShow: onStartShow, onStopShow: onStopShow, @@ -1863,5 +1816,107 @@ extension View { onStartNextAct: onStartNextAct ) } + .alert("You are ending a show with pause - no performace reports will be generated", isPresented: showAlertWhenEndingShowWithPause) { + Button("Cancel", role: .cancel) { } + Button("Pause and End show", action: endPerformanceWithoutEnd) + } + .sheet(isPresented: showingGoToSectionSheet) { + DSMGoToSection(performance: performance, goToLine: goToLine) + } + } +} + +struct DSMGoToSection: View { + let performance: Performance + var goToLine: (Int) -> Void + + @Environment(\.dismiss) var dismiss + + var body: some View { + List { + Group { + if let sections = self.performance.show?.script?.sections { + ForEach(sections, id: \.id) { section in + Button { + self.goToLine(section.startLineNumber) + dismiss() + } label: { + Label( + "\(section.title)", + systemImage: "" + ) + } + } + } else { + Text("No sections found!") + } + } + .navigationTitle("Go To Section") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + dismiss() + } label: { + Label( + "Dismiss", + systemImage: "" + ) + } + } + } + } + } +} + +struct MIDIConfigurationView: View { + let midiHelper: MIDIHelper + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + List { + Section("MIDI Program Change Mapping") { + ForEach(0...32, id: \.self) { program in + HStack { + Text("PC \(program)") + .font(.system(.body, design: .monospaced)) + .frame(width: 60, alignment: .leading) + + Picker("Action", selection: Binding( + get: { + midiHelper.programChangeMapping[program] ?? .none + }, + set: { + midiHelper.mapProgramChange(program, to: $0) + } + )) { + ForEach(MIDIHelper.RemoteAction.allCases, id: \.self) { action in + Text(action.rawValue).tag(action) + } + } + .pickerStyle(.menu) + } + } + } + + Section { + Text("Map MIDI Program Change messages (0-32) to remote actions. Use your MIDI controller to send Program Change messages on any channel.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Instructions") + } + } + .navigationTitle("MIDI Remote") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } } } diff --git a/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift b/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift index 3e80ecb..f966db9 100644 --- a/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift +++ b/Promptly/Views/Performance Mode/SpectatorPerformaceView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData +import Combine struct SpectatorPerformanceView: View { let showId: UUID @@ -46,7 +47,11 @@ struct SpectatorPerformanceView: View { .padding() .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(Color(.systemBackground)) +#if os(watchOS) + .foregroundColor(Color(.gray)) +#else + .foregroundColor(Color(.systemBackground)) +#endif .onChange(of: currentLine) { _, newValue in withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("line-\(newValue)", anchor: .center) @@ -119,10 +124,12 @@ struct SpectatorPerformanceView: View { private var spectatorHeader: some View { HStack { VStack(alignment: .leading, spacing: 2) { + #if !os(watchOS) Text(script.name) .font(.headline) - .fontWeight(.bold) + .fontWeight(.bold) .foregroundColor(.primary) + #endif HStack(spacing: 8) { Text(status.displayName) @@ -133,33 +140,45 @@ struct SpectatorPerformanceView: View { .background(status.color.opacity(0.2)) .cornerRadius(4) + #if !os(watchOS) Text("Spectator Mode") .font(.caption) .foregroundColor(.secondary) + #endif } } Spacer() +#if !os(watchOS) VStack(alignment: .trailing, spacing: 2) { Text(currentTime.formatted(date: .omitted, time: .standard)) - .font(.title2) + .font(.caption) .fontWeight(.bold) .monospacedDigit() .foregroundColor(.primary) Text("Line \(currentLine)") - .font(.caption) + .font(.footnote) .foregroundColor(.secondary) .monospacedDigit() } + #endif } .padding() - .background(Color(.systemGray6)) +#if os(watchOS) + .foregroundColor(Color(.gray)) +#else + .foregroundColor(Color(.systemGray6)) +#endif .overlay(alignment: .bottom) { Rectangle() .frame(height: 1) + #if os(watchOS) + .foregroundColor(Color(.gray)) + #else .foregroundColor(Color(.separator)) + #endif } } } @@ -170,7 +189,7 @@ struct TimeCallOverlay: View { var body: some View { ZStack { - Color.red + Color.black .ignoresSafeArea() VStack { diff --git a/Promptly/Views/Scripts/.DS_Store b/Promptly/Views/Scripts/.DS_Store index 9f5e630..dc1d6b1 100644 Binary files a/Promptly/Views/Scripts/.DS_Store and b/Promptly/Views/Scripts/.DS_Store differ diff --git a/Promptly/Views/Scripts/ScriptEditorView.swift b/Promptly/Views/Scripts/ScriptEditorView.swift index 7a9f0c5..dd84e28 100644 --- a/Promptly/Views/Scripts/ScriptEditorView.swift +++ b/Promptly/Views/Scripts/ScriptEditorView.swift @@ -480,30 +480,3 @@ struct FlowResult { self.size = CGSize(width: maxWidth, height: currentRowY + currentRowHeight) } } - -extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (1, 1, 1, 0) - } - - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) - } -} diff --git a/Promptly/Views/Shows/.DS_Store b/Promptly/Views/Shows/.DS_Store index 968a3c2..1b229ee 100644 Binary files a/Promptly/Views/Shows/.DS_Store and b/Promptly/Views/Shows/.DS_Store differ diff --git a/Promptly/Views/Shows/Detail/ShowDetailView.swift b/Promptly/Views/Shows/Detail/ShowDetailView.swift index 8ad34b2..8ccd00f 100644 --- a/Promptly/Views/Shows/Detail/ShowDetailView.swift +++ b/Promptly/Views/Shows/Detail/ShowDetailView.swift @@ -152,6 +152,7 @@ struct ShowDetailView: View { performanceToStart = nil }, content: { DSMPerformanceView(performance: $0) + .interactiveDismissDisabled(true) }) .onAppear { print("📅 Performance dates: \(show.performanceDates.count)") @@ -220,21 +221,21 @@ struct ShowDetailView: View { .cornerRadius(8) } - Button(role: .destructive) { - show.performanceDates.removeAll() - show.peformances.removeAll() - let today = Date() - addPerformance(date: today) - } label: { - HStack { - Image(systemName: "trash") - Text("Delete All & Add Today") - Spacer() - } - .padding() - .background(Color(.tertiarySystemGroupedBackground)) - .cornerRadius(8) - } + // Button(role: .destructive) { + // show.performanceDates.removeAll() + // show.peformances.removeAll() + // let today = Date() + // addPerformance(date: today) + // } label: { + // HStack { + // Image(systemName: "trash") + // Text("Delete All & Add Today") + // Spacer() + // } + // .padding() + // .background(Color(.tertiarySystemGroupedBackground)) + // .cornerRadius(8) + // } } } } diff --git a/test1_results.rtf b/test1_results.rtf new file mode 100644 index 0000000..548e644 --- /dev/null +++ b/test1_results.rtf @@ -0,0 +1,28 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2865 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica-Bold;} +{\colortbl;\red255\green255\blue255;\red0\green0\blue255;\red251\green2\blue7;} +{\*\expandedcolortbl;;\cssrgb\c1680\c19835\c100000;\cssrgb\c100000\c14913\c0;} +\paperw11900\paperh16840\margl1440\margr1440\vieww29200\viewh16280\viewkind0 +\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 + +\f0\b\fs96 \cf2 PLEASE INTERACT WITH APP (move lines via click (no arrow workings), (line not acc));\ +\cf0 \ +\cf3 DO NOT STOP SHOW / CHANGE STATE;\ +TESTING FOR DURATION;\ +\ +-sb;\ +\ +\ +NOTES:\ +- no wifi/internet test\ +- brightness 40%\ +- consistent 100mb memory \ +- consistent 13.9 energy\ +- over 1h, loss of 12% battery, no power source\ +- cpu 5 active, 2.2 foreground\ +- no loss of frame rate / crashes 1h in\ +- 116.4mb on fast scrolling of lines and also when skipping like 2k lines stays around the same mb\ +- total tst runtime 02:21:40\ +- no crashes\ +- stable experience \ +} \ No newline at end of file