Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Promptly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@
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.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -475,7 +475,7 @@
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.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
<key>Promptly-WatchOS Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>3</integer>
</dict>
<key>Promptly.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>2</integer>
</dict>
</dict>
</dict>
Expand Down
98 changes: 62 additions & 36 deletions Promptly/Helpers/MIDIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,22 @@
// MIDIHelper.swift
// MIDIKit • https://github.com/orchetect/MIDIKit
// © 2021-2025 Steffan Andrews • Licensed under MIT License
//
// Edited by Sasha Bagrov - 20/10/2025

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() { }
Expand All @@ -42,58 +35,72 @@ import SwiftUI
setupConnections()
}

// MARK: - Connection Names
static let universalInputConnectionName = "Universal MIDI Input"
static let usbOutputConnectionName = "USB MIDI Output"
static let bleOutputConnectionName = "BLE MIDI Output"

private func setupConnections() {
guard let midiManager else { return }

do {
print("Creating universal MIDI input connection.")
try midiManager.addInputConnection(
to: .allOutputs,
tag: "Listener",
tag: Self.universalInputConnectionName,
filter: .owned(),
receiver: .events { [weak self] events,_,_ in
receiver: .events { [weak self] events, timeStamp, source in
print("MIDI from source: \(source)")
self?.handleMIDIEvents(events)
}
)
} catch {
print("Error setting up MIDI connection:", error.localizedDescription)
}

// Keep your broadcaster
do {

print("Creating USB MIDI output connection.")
try midiManager.addOutputConnection(
to: .inputs(matching: [.name("IDAM MIDI Host")]),
tag: Self.usbOutputConnectionName
)

print("Creating BLE MIDI output connection.")
try midiManager.addOutputConnection(
to: .allInputs,
tag: "Broadcaster",
tag: Self.bleOutputConnectionName,
filter: .owned()
)

} catch {
print("Error setting up broadcaster connection:", error.localizedDescription)
print("Error setting up MIDI connections:", error.localizedDescription)
}
}

// MARK: - Event Handling
private func handleMIDIEvents(_ events: [MIDIEvent]) {
for event in events {
switch event {
case .programChange(let programChange):
handleProgramChange(program: programChange.program, channel: programChange.channel)
case .noteOn(let noteOn):
print("Note On: \(noteOn.note) velocity: \(noteOn.velocity)")
case .noteOff(let noteOff):
print("Note Off: \(noteOff.note)")
case .cc(let cc):
print("CC: \(cc.controller) value: \(cc.value)")
default:
// Log other events for debugging
print("MIDI Event: \(event)")
print("Other 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)")
Expand All @@ -102,26 +109,20 @@ import SwiftUI

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
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
// MARK: - Configuration
func mapProgramChange(_ program: Int, to action: RemoteAction) {
guard program >= 0 && program <= 32 else { return }
programChangeMapping[program] = action
Expand All @@ -132,8 +133,33 @@ import SwiftUI
programChangeMapping[program] = .none
}

func sendTestMIDIEvent() {
let conn = midiManager?.managedOutputConnections["Broadcaster"]
try? conn?.send(event: .cc(.expression, value: .midi1(64), channel: 0))
// MARK: - Output Methods
var usbOutputConnection: MIDIOutputConnection? {
midiManager?.managedOutputConnections[Self.usbOutputConnectionName]
}

var bleOutputConnection: MIDIOutputConnection? {
midiManager?.managedOutputConnections[Self.bleOutputConnectionName]
}

func sendNoteOnUSB() {
try? usbOutputConnection?.send(event: .noteOn(60, velocity: .midi1(127), channel: 0))
}

func sendNoteOffUSB() {
try? usbOutputConnection?.send(event: .noteOff(60, velocity: .midi1(0), channel: 0))
}

func sendCC1USB() {
try? usbOutputConnection?.send(event: .cc(1, value: .midi1(64), channel: 0))
}

func sendTestMIDIEventBLE() {
try? bleOutputConnection?.send(event: .cc(.expression, value: .midi1(64), channel: 0))
}

func sendToAll(event: MIDIEvent) {
try? usbOutputConnection?.send(event: event)
try? bleOutputConnection?.send(event: event)
}
}
18 changes: 16 additions & 2 deletions Promptly/Models/Script.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,18 @@ class ScriptLine: Identifiable {
var id: UUID
var lineNumber: Int
var content: String // Raw text content
var flags: [ScriptLineFlags] = []
@Relationship(deleteRule: .cascade) var elements: [LineElement] = []
@Relationship(deleteRule: .cascade) var cues: [Cue] = []
var isMarked: Bool = false
var markColor: String? // Hex color for line marking
var notes: String = ""

init(id: UUID, lineNumber: Int, content: String) {
init(id: UUID, lineNumber: Int, content: String, flags: [ScriptLineFlags]) {
self.id = id
self.lineNumber = lineNumber
self.content = content
self.flags = flags
// Parse content after initialization
DispatchQueue.main.async {
self.parseContentIntoElements()
Expand Down Expand Up @@ -115,6 +117,11 @@ class ScriptLine: Identifiable {
}
}

enum ScriptLineFlags: String, CaseIterable, Codable {
case stageDirection = "stageDirection"
case skip = "skip"
}

@Model
class LineElement: Identifiable {
var id: UUID
Expand Down Expand Up @@ -262,6 +269,7 @@ extension ScriptLine {
"id": id.uuidString,
"lineNumber": lineNumber,
"content": content,
"flags": flags.map { $0.rawValue }, // Convert enum cases to strings
"elements": elements.map { $0.toDictionary() },
"cues": cues.map { $0.toDictionary() },
"isMarked": isMarked,
Expand Down Expand Up @@ -351,7 +359,13 @@ extension ScriptLine {
return nil
}

self.init(id: id, lineNumber: lineNumber, content: content)
// Parse flags from dictionary
var flags: [ScriptLineFlags] = []
if let flagsArray = dict["flags"] as? [String] {
flags = flagsArray.compactMap { ScriptLineFlags(rawValue: $0) }
}

self.init(id: id, lineNumber: lineNumber, content: content, flags: flags)

if let elementsArray = dict["elements"] as? [[String: Any]] {
self.elements = elementsArray.compactMap { LineElement(from: $0) }
Expand Down
11 changes: 10 additions & 1 deletion Promptly/Parser/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,19 @@ class PDFScriptParser: ObservableObject {

// Convert to ScriptLine objects
for (index, lineText) in scriptLines.enumerated() {
// Auto-detect flags based on line type
let lineType = detectLineType(lineText)
var flags: [ScriptLineFlags] = []

if lineType == .stageDirection {
flags.append(.stageDirection)
}

let scriptLine = ScriptLine(
id: UUID(),
lineNumber: index + 1,
content: lineText
content: lineText,
flags: flags
)
script.lines.append(scriptLine)
}
Expand Down
15 changes: 15 additions & 0 deletions Promptly/Views/Performance Mode/DSMScriptLineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ struct DSMScriptLineView: View {
let onLineTap: () -> Void
let calledCues: Set<UUID>

private let isStageDirection: Bool

init(line: ScriptLine, isCurrent: Bool, onLineTap: @escaping () -> Void, calledCues: Set<UUID>) {
self.line = line
self.isCurrent = isCurrent
self.onLineTap = onLineTap
self.calledCues = calledCues
self.isStageDirection = line.flags.contains(.stageDirection)
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button(action: onLineTap) {
Expand Down Expand Up @@ -62,6 +72,7 @@ struct DSMScriptLineView: View {

return Text(buildLineWithCues(words: words, cuesByIndex: cuesByIndex))
.font(.body)
.italic(isStageDirection)
.foregroundColor(isCurrent ? .black : .primary)
}

Expand All @@ -84,6 +95,10 @@ struct DSMScriptLineView: View {
}

var wordAttr = AttributedString(word + " ")

if isStageDirection {
wordAttr.inlinePresentationIntent = .emphasized
}
result += wordAttr
}

Expand Down
24 changes: 22 additions & 2 deletions Promptly/Views/Performance Mode/LivePerforemanceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1678,8 +1678,28 @@ extension DSMPerformanceView {

private func moveToLine(_ lineNumber: Int) {
guard lineNumber >= 1 && lineNumber <= sortedLinesCache.count else { return }
currentLineNumber = lineNumber
self.mqttManager.sendData(to: "shows/\(self.uuidOfShow)/line", message: String(lineNumber))

var targetLineNumber = lineNumber
let isMovingForward = targetLineNumber > currentLineNumber

while targetLineNumber >= 1 && targetLineNumber <= sortedLinesCache.count {
let targetLine = sortedLinesCache[targetLineNumber - 1]

if targetLine.flags.contains(.skip) {
if isMovingForward {
targetLineNumber += 1
} else {
targetLineNumber -= 1
}
} else {
break
}
}

targetLineNumber = max(1, min(targetLineNumber, sortedLinesCache.count))

currentLineNumber = targetLineNumber
self.mqttManager.sendData(to: "shows/\(self.uuidOfShow)/line", message: String(targetLineNumber))
}

private func logCall(_ message: String, type: CallLogEntry.CallType = .note) {
Expand Down
Loading
Loading