diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 2c86ebe..a4f684b 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 681A0AB827D88B190097E3F4 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 683F05F4273F96B20080C7AC /* ReplayKit.framework */; platformFilter = maccatalyst; }; 6830E6BE2D5BE5E2001C5E83 /* AudioControlsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6830E6BD2D5BE5DF001C5E83 /* AudioControlsPanel.swift */; }; 6830E6BF2D5BE5E3001C5E83 /* MessagesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6830E6C02D5BE5E0001C5E83 /* MessagesPanel.swift */; }; + DS00000000000000000001 /* DataStreamModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DS00000000000000000002 /* DataStreamModels.swift */; }; + DS00000000000000000003 /* DataStreamsContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DS00000000000000000004 /* DataStreamsContext.swift */; }; + DS00000000000000000005 /* DataStreamsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DS00000000000000000006 /* DataStreamsPanel.swift */; }; 68698E642C4C219500221782 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 68698E632C4C219500221782 /* KeychainAccess */; }; 68698E662C4C219A00221782 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 68698E652C4C219A00221782 /* SFSafeSymbols */; }; 6888FBE12C66B7B400AB93C1 /* ImmersiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888FBE02C66B7B100AB93C1 /* ImmersiveView.swift */; }; @@ -70,6 +73,9 @@ /* Begin PBXFileReference section */ 6830E6BD2D5BE5DF001C5E83 /* AudioControlsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioControlsPanel.swift; sourceTree = ""; }; 6830E6C02D5BE5E0001C5E83 /* MessagesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPanel.swift; sourceTree = ""; }; + DS00000000000000000002 /* DataStreamModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStreamModels.swift; sourceTree = ""; }; + DS00000000000000000004 /* DataStreamsContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStreamsContext.swift; sourceTree = ""; }; + DS00000000000000000006 /* DataStreamsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStreamsPanel.swift; sourceTree = ""; }; 683F05F3273F96B20080C7AC /* BroadcastExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BroadcastExt.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 683F05F4273F96B20080C7AC /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; 683F05F9273F96B20080C7AC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -163,6 +169,7 @@ 68A50ECA2C4C1ED500D2DE17 /* Controllers */ = { isa = PBXGroup; children = ( + DS00000000000000000004 /* DataStreamsContext.swift */, 68A50EC82C4C1ED500D2DE17 /* AppContext.swift */, 68A50EC92C4C1ED500D2DE17 /* RoomContext.swift */, ); @@ -172,6 +179,7 @@ 68A50ED12C4C1ED500D2DE17 /* Support */ = { isa = PBXGroup; children = ( + DS00000000000000000002 /* DataStreamModels.swift */, 68A50ECD2C4C1ED500D2DE17 /* ConnectionHistory.swift */, 68A50ECE2C4C1ED500D2DE17 /* ExampleRoomMessage.swift */, 68A50ECF2C4C1ED500D2DE17 /* Participant+Helpers.swift */, @@ -183,6 +191,7 @@ 68A50ED42C4C1ED500D2DE17 /* Views */ = { isa = PBXGroup; children = ( + DS00000000000000000006 /* DataStreamsPanel.swift */, 7BBEBA842D79213900586EC4 /* Shared */, 7BBEBA7B2D79104D00586EC4 /* RoomSwitchView.swift */, 7BBEBA792D79103800586EC4 /* RoomContextView.swift */, @@ -388,6 +397,9 @@ 68A50EE02C4C1ED500D2DE17 /* LiveKitExample.swift in Sources */, 6830E6BE2D5BE5E2001C5E83 /* AudioControlsPanel.swift in Sources */, 6830E6BF2D5BE5E3001C5E83 /* MessagesPanel.swift in Sources */, + DS00000000000000000001 /* DataStreamModels.swift in Sources */, + DS00000000000000000003 /* DataStreamsContext.swift in Sources */, + DS00000000000000000005 /* DataStreamsPanel.swift in Sources */, 7BBEBA7A2D79103800586EC4 /* RoomContextView.swift in Sources */, 68A50EE22C4C1ED500D2DE17 /* RoomView.swift in Sources */, 68A50EE32C4C1ED500D2DE17 /* PublishOptionsView.swift in Sources */, diff --git a/Multiplatform/Controllers/DataStreamsContext.swift b/Multiplatform/Controllers/DataStreamsContext.swift new file mode 100644 index 0000000..d71e556 --- /dev/null +++ b/Multiplatform/Controllers/DataStreamsContext.swift @@ -0,0 +1,301 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import LiveKit + +// Drives the data streams panel: composing outgoing streams and subscribing to +// incoming ones. +// +// Stream handler registrations are Room-scoped and outlive a session, so they are +// added when the user adds a subscription and removed when the user removes it — +// never on connect or disconnect. That keeps subscriptions working across a +// reconnect and means there is no path that re-registers an already-registered +// topic. +@MainActor +final class DataStreamsContext: ObservableObject { + private let room: Room + + // Send form + @Published var sendKind: DataStreamKind = .text + @Published var sendTopic: String = "demo" + /// Identity `stringValue` of the target participant; empty means broadcast. + @Published var sendDestination: String = "" + @Published var sendContent: String = "" + @Published var isSending: Bool = false + @Published var sendStatus: DataStreamSendStatus = .idle + + // Subscribe form + @Published var newSubscriptionTopic: String = "" + @Published var newSubscriptionKind: DataStreamKind = .text + + @Published private(set) var subscriptions: [DataStreamSubscription] = [] + + /// Serializes register/unregister so that removing and immediately re-adding the + /// same topic cannot interleave into a `handlerAlreadyRegistered` error. + private var registrationTask: Task? + + init(room: Room) { + self.room = room + } + + // MARK: - Sending + + var canSend: Bool { + !isSending && !sendTopic.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func applyHelloWorldPreset() { + sendContent = "hello world" + } + + func applyRandomPreset() { + sendContent = dataStreamRandomString(length: DataStreamLimits.randomPayloadLength) + } + + func send() { + let topic = sendTopic.trimmingCharacters(in: .whitespacesAndNewlines) + guard !topic.isEmpty, !isSending else { return } + + // Resolve the destination at send time, falling back to broadcast if the + // selected participant has since left. + let destinations: [Participant.Identity] + if !sendDestination.isEmpty, + room.remoteParticipants.keys.contains(where: { $0.stringValue == sendDestination }) + { + destinations = [Participant.Identity(from: sendDestination)] + } else { + destinations = [] + } + + let kind = sendKind + let content = sendContent + isSending = true + sendStatus = .sending + + Task { [weak self] in + guard let self else { return } + do { + let streamID: String + let byteCount: Int + + switch kind { + case .text: + // Convenience initializer; omitting `compress:` disambiguates it + // from the designated one. + let options = StreamTextOptions(topic: topic, destinationIdentities: destinations) + let info = try await room.localParticipant.sendText(content, options: options) + streamID = info.id + byteCount = content.utf8.count + + case .bytes: + // There is no in-memory byte send, so stage the payload in a + // temporary file and send that. + let data = Data(content.utf8) + let directory = try Self.writeTemporaryFile(data: data) + defer { try? FileManager.default.removeItem(at: directory) } + let options = StreamByteOptions(topic: topic, destinationIdentities: destinations) + let info = try await room.localParticipant.sendFile(directory.appendingPathComponent(Self.temporaryFileName), + options: options) + streamID = info.id + byteCount = data.count + } + + sendStatus = .sent(streamID: streamID, byteCount: byteCount) + } catch { + sendStatus = .failed(Self.describe(error)) + } + isSending = false + } + } + + private static let temporaryFileName = "test.txt" + + /// Stages an outgoing byte payload on disk. `sendFile` fills in the stream's + /// name, MIME type and total size from the file itself. Returns the enclosing + /// directory so the caller can delete the whole thing afterwards. + private static func writeTemporaryFile(data: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("lk-data-streams-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(temporaryFileName)) + return directory + } + + // MARK: - Subscriptions + + func canAddSubscription(topic: String, kind: DataStreamKind) -> Bool { + let trimmed = topic.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let key = DataStreamSubscriptionKey(topic: trimmed, kind: kind) + return !subscriptions.contains { $0.id == key } + } + + func addSubscription() { + let topic = newSubscriptionTopic.trimmingCharacters(in: .whitespacesAndNewlines) + guard canAddSubscription(topic: topic, kind: newSubscriptionKind) else { return } + + let key = DataStreamSubscriptionKey(topic: topic, kind: newSubscriptionKind) + // Insert synchronously so a double tap cannot race past the duplicate check. + subscriptions.append(DataStreamSubscription(id: key)) + newSubscriptionTopic = "" + + enqueueRegistration { [weak self] in + guard let self else { return } + do { + switch key.kind { + case .text: + try await room.registerTextStreamHandler(for: key.topic, + onNewStream: makeTextHandler(for: key)) + case .bytes: + try await room.registerByteStreamHandler(for: key.topic, + onNewStream: makeByteHandler(for: key)) + } + } catch { + // Keep the row so the failure stays visible and removable. + if let index = subscriptions.firstIndex(where: { $0.id == key }) { + subscriptions[index].registrationError = Self.describe(error) + } + } + } + } + + func removeSubscription(_ key: DataStreamSubscriptionKey) { + subscriptions.removeAll { $0.id == key } + + enqueueRegistration { [weak self] in + guard let self else { return } + switch key.kind { + case .text: await room.unregisterTextStreamHandler(for: key.topic) + case .bytes: await room.unregisterByteStreamHandler(for: key.topic) + } + } + } + + func toggleExpansion(payloadID: UUID, in key: DataStreamSubscriptionKey) { + guard let subscriptionIndex = subscriptions.firstIndex(where: { $0.id == key }), + let payloadIndex = subscriptions[subscriptionIndex].payloads.firstIndex(where: { $0.id == payloadID }) + else { return } + subscriptions[subscriptionIndex].payloads[payloadIndex].isExpanded.toggle() + } + + /// Keeps the subscription list — registrations survive a reconnect — but drops + /// everything tied to the finished session. + func roomDidDisconnect() { + for index in subscriptions.indices { + subscriptions[index].payloads.removeAll() + subscriptions[index].totalReceived = 0 + } + sendDestination = "" + sendStatus = .idle + isSending = false + } + + private func enqueueRegistration(_ operation: @escaping @MainActor () async -> Void) { + let previous = registrationTask + registrationTask = Task { @MainActor in + await previous?.value + await operation() + } + } + + // MARK: - Receiving + + // The SDK invokes handlers on a detached task and discards anything they throw, + // so each one catches internally and surfaces the error as a payload row. The + // read, decode and truncation all happen off the main actor; only the finished + // value crosses over. + + private func makeTextHandler(for key: DataStreamSubscriptionKey) -> TextStreamHandler { + { [weak self] reader, identity in + guard let self else { return } + let streamID = reader.info.id + do { + let text = try await reader.readAll() + let body = ReceivedPayload.Body.text(dataStreamCapped(text)) + await record(body, + sender: identity.stringValue, + byteCount: text.utf8.count, + streamID: streamID, + for: key) + } catch { + await record(.failure(Self.describe(error)), + sender: identity.stringValue, + byteCount: 0, + streamID: streamID, + for: key) + } + } + } + + private func makeByteHandler(for key: DataStreamSubscriptionKey) -> ByteStreamHandler { + { [weak self] reader, identity in + guard let self else { return } + let streamID = reader.info.id + do { + let data = try await reader.readAll() + let body: ReceivedPayload.Body = if let text = String(data: data, encoding: .utf8) { + .text(dataStreamCapped(text)) + } else { + .hex(dataStreamHexDump(data)) + } + await record(body, + sender: identity.stringValue, + byteCount: data.count, + streamID: streamID, + for: key) + } catch { + await record(.failure(Self.describe(error)), + sender: identity.stringValue, + byteCount: 0, + streamID: streamID, + for: key) + } + } + } + + private func record(_ body: ReceivedPayload.Body, + sender: String, + byteCount: Int, + streamID: String, + for key: DataStreamSubscriptionKey) + { + // A stream can land after its subscription was removed; dropping it is correct. + guard let index = subscriptions.firstIndex(where: { $0.id == key }) else { return } + + subscriptions[index].totalReceived += 1 + subscriptions[index].payloads.append( + ReceivedPayload(id: UUID(), + sequence: subscriptions[index].totalReceived, + sender: sender, + receivedAt: Date(), + byteCount: byteCount, + streamID: streamID, + body: body) + ) + + let overflow = subscriptions[index].payloads.count - DataStreamLimits.retainedPayloads + if overflow > 0 { + subscriptions[index].payloads.removeFirst(overflow) + } + } + + static func describe(_ error: Error) -> String { + if let error = error as? StreamError { return String(describing: error) } + if let error = error as? LiveKitError { return error.errorDescription ?? "\(error)" } + return error.localizedDescription + } +} diff --git a/Multiplatform/Controllers/RoomContext.swift b/Multiplatform/Controllers/RoomContext.swift index a237192..3c36015 100644 --- a/Multiplatform/Controllers/RoomContext.swift +++ b/Multiplatform/Controllers/RoomContext.swift @@ -32,6 +32,8 @@ final class RoomContext: ObservableObject { let room = Room() + let dataStreamsCtx: DataStreamsContext + @Published var url: String = "" { didSet { store.value.url = url } } @@ -81,7 +83,10 @@ final class RoomContext: ObservableObject { @Published var showMessagesPanel: Bool = false { didSet { - if showMessagesPanel { showAudioPanel = false } + if showMessagesPanel { + showAudioPanel = false + showDataStreamsPanel = false + } } } @@ -91,7 +96,19 @@ final class RoomContext: ObservableObject { @Published var showAudioPanel: Bool = false { didSet { - if showAudioPanel { showMessagesPanel = false } + if showAudioPanel { + showMessagesPanel = false + showDataStreamsPanel = false + } + } + } + + @Published var showDataStreamsPanel: Bool = false { + didSet { + if showDataStreamsPanel { + showMessagesPanel = false + showAudioPanel = false + } } } @@ -113,6 +130,7 @@ final class RoomContext: ObservableObject { init(store: ValueStore) { self.store = store + dataStreamsCtx = DataStreamsContext(room: room) room.add(delegate: self) url = store.value.url @@ -267,6 +285,16 @@ extension RoomContext: RoomDelegate { nonisolated func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState, from oldValue: ConnectionState) { print("Did update connectionState \(oldValue) -> \(connectionState)") + // Runs on every disconnect, unlike the reset below which is limited to + // abnormal ones. + if case .disconnected = connectionState { + Task { @MainActor [weak self] in + guard let self else { return } + showDataStreamsPanel = false + dataStreamsCtx.roomDidDisconnect() + } + } + if case .disconnected = connectionState, let error = room.disconnectError, error.type != .cancelled diff --git a/Multiplatform/Support/DataStreamModels.swift b/Multiplatform/Support/DataStreamModels.swift new file mode 100644 index 0000000..d54d7f4 --- /dev/null +++ b/Multiplatform/Support/DataStreamModels.swift @@ -0,0 +1,128 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// Value types backing the data streams panel. These are deliberately free of any +// actor isolation so stream handlers can build them off the main actor and hand +// the finished value across in a single hop. + +enum DataStreamKind: String, CaseIterable, Identifiable, Hashable, Sendable { + case text + case bytes + + var id: String { rawValue } + var title: String { self == .text ? "Text" : "Bytes" } + var badge: String { self == .text ? "[text]" : "[bytes]" } +} + +/// Identifies a subscription. The SDK keeps separate registries for text and byte +/// handlers, so the same topic may legally have one subscription of each kind. +struct DataStreamSubscriptionKey: Hashable, Sendable { + let topic: String + let kind: DataStreamKind +} + +struct ReceivedPayload: Identifiable, Equatable, Sendable { + enum Body: Equatable, Sendable { + case text(String) // already length-capped + case hex(String) // pre-rendered hex dump, already capped + case failure(String) // the reader threw; message for display + } + + let id: UUID + let sequence: Int // 1-based, monotonic per subscription + let sender: String + let receivedAt: Date + let byteCount: Int // true size, before truncation + let streamID: String + let body: Body + var isExpanded: Bool = false +} + +struct DataStreamSubscription: Identifiable, Sendable { + let id: DataStreamSubscriptionKey + var payloads: [ReceivedPayload] = [] + /// Monotonic total; unlike `payloads.count` this is not reduced by the retention cap. + var totalReceived: Int = 0 + var registrationError: String? + + var topic: String { id.topic } + var kind: DataStreamKind { id.kind } +} + +enum DataStreamSendStatus: Equatable, Sendable { + case idle + case sending + case sent(streamID: String, byteCount: Int) + case failed(String) +} + +enum DataStreamLimits { + static let retainedPayloads = 100 + static let storedBodyCharacters = 4096 + static let hexDumpBytes = 512 + static let randomPayloadLength = 20000 +} + +// MARK: - Formatting helpers + +private let hexAlphabet = Array("0123456789abcdef") + +func dataStreamHexDump(_ data: Data, maxBytes: Int = DataStreamLimits.hexDumpBytes) -> String { + let shown = data.prefix(maxBytes) + var out = "" + out.reserveCapacity(shown.count * 3) + for (offset, byte) in shown.enumerated() { + if offset > 0 { out.append(" ") } + out.append(hexAlphabet[Int(byte >> 4)]) + out.append(hexAlphabet[Int(byte & 0x0F)]) + } + if data.count > maxBytes { + out += " … (+\(data.count - maxBytes) more bytes)" + } + return out +} + +func dataStreamCapped(_ text: String, max: Int = DataStreamLimits.storedBodyCharacters) -> String { + guard text.count > max else { return text } + return String(text.prefix(max)) + " … (+\(text.count - max) more characters)" +} + +func dataStreamRandomString(length: Int) -> String { + let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".utf8) + var bytes = [UInt8]() + bytes.reserveCapacity(length) + for _ in 0 ..< length { + bytes.append(alphabet.randomElement()!) + } + return String(decoding: bytes, as: UTF8.self) +} + +func dataStreamByteCountLabel(_ count: Int) -> String { + if count < 1024 { return "\(count) B" } + return String(format: "%.1f kB", Double(count) / 1024.0) +} + +private let dataStreamTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter +}() + +func dataStreamTimeLabel(_ date: Date) -> String { + dataStreamTimeFormatter.string(from: date) +} diff --git a/Multiplatform/Views/DataStreamsPanel.swift b/Multiplatform/Views/DataStreamsPanel.swift new file mode 100644 index 0000000..460efeb --- /dev/null +++ b/Multiplatform/Views/DataStreamsPanel.swift @@ -0,0 +1,287 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import LiveKit +import SFSafeSymbols +import SwiftUI + +// TextEditor is unavailable on tvOS. +#if !os(tvOS) +struct DataStreamsPanel: View { + @EnvironmentObject var room: Room + @EnvironmentObject var dataStreamsCtx: DataStreamsContext + + private var remoteIdentities: [String] { + // Participant.Identity is not Comparable, so sort the string values. + room.remoteParticipants.keys.map(\.stringValue).sorted() + } + + /// Presents "All participants" whenever the selected participant is gone. + private var destination: Binding { + Binding( + get: { + let current = dataStreamsCtx.sendDestination + return remoteIdentities.contains(current) ? current : "" + }, + set: { dataStreamsCtx.sendDestination = $0 } + ) + } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + sendSection + Divider() + subscriptionsSection + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Send + + private var sendSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Send") + .font(.headline) + + Picker("Kind", selection: $dataStreamsCtx.sendKind) { + ForEach(DataStreamKind.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + + fieldLabel("Topic") + plainField("Topic", text: $dataStreamsCtx.sendTopic) + + fieldLabel("To") + Picker("To", selection: destination) { + Text("All participants").tag("") + ForEach(remoteIdentities, id: \.self) { Text($0).tag($0) } + } + .pickerStyle(.menu) + .labelsHidden() + + fieldLabel("Content") + HStack(spacing: 8) { + Button("Hello world") { dataStreamsCtx.applyHelloWorldPreset() } + Button("20k random") { dataStreamsCtx.applyRandomPreset() } + } + .buttonStyle(.bordered) + .font(.caption) + + TextEditor(text: $dataStreamsCtx.sendContent) + .font(.system(size: 11, design: .monospaced)) + .frame(height: 90) + .scrollContentBackground(.hidden) + .padding(4) + .overlay(RoundedRectangle(cornerRadius: 10.0) + .strokeBorder(Color.white.opacity(0.3), style: StrokeStyle(lineWidth: 1.0))) + + Text("\(dataStreamsCtx.sendContent.count) characters") + .font(.caption2) + .foregroundColor(.secondary) + + HStack(spacing: 10) { + LKButton(title: "Send") { dataStreamsCtx.send() } + .opacity(dataStreamsCtx.canSend ? 1 : 0.5) + .disabled(!dataStreamsCtx.canSend) + + if dataStreamsCtx.isSending { + ProgressView().controlSize(.small) + } + } + + sendStatusView + } + } + + @ViewBuilder + private var sendStatusView: some View { + switch dataStreamsCtx.sendStatus { + case .idle: + EmptyView() + case .sending: + Text("Sending…") + .font(.caption) + .foregroundColor(.secondary) + case let .sent(streamID, byteCount): + Text("Sent \(String(streamID.prefix(8)))… (\(dataStreamByteCountLabel(byteCount)))") + .font(.caption) + .foregroundColor(.green) + case let .failed(message): + Text("Error: \(message)") + .font(.caption) + .foregroundColor(Color.lkRed) + } + } + + // MARK: - Subscriptions + + private var subscriptionsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Subscriptions (\(dataStreamsCtx.subscriptions.count))") + .font(.headline) + + Picker("Kind", selection: $dataStreamsCtx.newSubscriptionKind) { + ForEach(DataStreamKind.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + + HStack(spacing: 8) { + plainField("Topic", text: $dataStreamsCtx.newSubscriptionTopic) + Button("Add") { dataStreamsCtx.addSubscription() } + .buttonStyle(.bordered) + .disabled(!canAddSubscription) + } + + if dataStreamsCtx.subscriptions.isEmpty { + Text("No subscriptions yet") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(dataStreamsCtx.subscriptions) { subscription in + SubscriptionCard(subscription: subscription) + } + } + } + } + + private var canAddSubscription: Bool { + dataStreamsCtx.canAddSubscription(topic: dataStreamsCtx.newSubscriptionTopic, + kind: dataStreamsCtx.newSubscriptionKind) + } + + // MARK: - Shared bits + + private func fieldLabel(_ title: String) -> some View { + Text(title) + .font(.caption) + .fontWeight(.bold) + } + + private func plainField(_ placeholder: String, text: Binding) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .disableAutocorrection(true) + .padding(8) + .overlay(RoundedRectangle(cornerRadius: 10.0) + .strokeBorder(Color.white.opacity(0.3), style: StrokeStyle(lineWidth: 1.0))) + #if os(iOS) + .autocapitalization(.none) + .keyboardType(.asciiCapable) + #endif + } +} + +private struct SubscriptionCard: View { + @EnvironmentObject var dataStreamsCtx: DataStreamsContext + + let subscription: DataStreamSubscription + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(subscription.topic) + .font(.system(size: 13, weight: .bold, design: .monospaced)) + Text(subscription.kind.badge) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Button { + dataStreamsCtx.removeSubscription(subscription.id) + } label: { + Image(systemSymbol: .xmarkCircleFill) + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + } + + if let registrationError = subscription.registrationError { + Text(registrationError) + .font(.caption2) + .foregroundColor(Color.lkRed) + } + + Text("Received (\(subscription.totalReceived))") + .font(.caption) + .foregroundColor(.secondary) + + if subscription.payloads.isEmpty { + Text("Nothing received yet") + .font(.caption) + .foregroundColor(.secondary) + } else { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(subscription.payloads) { payload in + PayloadRow(payload: payload, subscriptionID: subscription.id) + Divider() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 170) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.lkGray2) + .cornerRadius(8) + } +} + +private struct PayloadRow: View { + @EnvironmentObject var dataStreamsCtx: DataStreamsContext + + let payload: ReceivedPayload + let subscriptionID: DataStreamSubscriptionKey + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("#\(payload.sequence) · \(payload.sender) · \(dataStreamByteCountLabel(payload.byteCount)) · \(dataStreamTimeLabel(payload.receivedAt))") + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Text(bodyText) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(bodyColor) + .lineLimit(payload.isExpanded ? nil : 1) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + dataStreamsCtx.toggleExpansion(payloadID: payload.id, in: subscriptionID) + } + } + + private var bodyText: String { + switch payload.body { + case let .text(text): text + case let .hex(hex): hex + case let .failure(message): "" + } + } + + private var bodyColor: Color { + if case .failure = payload.body { return Color.lkRed } + return .primary + } +} +#endif diff --git a/Multiplatform/Views/RoomContextView.swift b/Multiplatform/Views/RoomContextView.swift index 906751f..1ed3d68 100644 --- a/Multiplatform/Views/RoomContextView.swift +++ b/Multiplatform/Views/RoomContextView.swift @@ -25,6 +25,7 @@ struct RoomContextView: View { RoomSwitchView() .environmentObject(roomCtx) .environmentObject(roomCtx.room) + .environmentObject(roomCtx.dataStreamsCtx) .foregroundColor(Color.white) .onDisappear { print("\(String(describing: type(of: self))) onDisappear") diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 5f7ffea..5a5932d 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -109,6 +109,16 @@ struct RoomView: View { maxWidth: geometry.isTall ? .infinity : 320 ) } + + func dataStreamsPanel(geometry: GeometryProxy) -> some View { + DataStreamsPanel() + .background(Color.lkGray1) + .cornerRadius(8) + .frame( + minWidth: 0, + maxWidth: geometry.isTall ? .infinity : 320 + ) + } #endif func sortedParticipants() -> [Participant] { @@ -183,6 +193,9 @@ struct RoomView: View { if roomCtx.showAudioPanel { audioControlsPanel(geometry: geometry) } + if roomCtx.showDataStreamsPanel { + dataStreamsPanel(geometry: geometry) + } #endif } } @@ -426,6 +439,16 @@ struct RoomView: View { Image(systemSymbol: .switch2) .foregroundColor(roomCtx.showAudioPanel ? .accentColor : nil) } + + // Toggle data streams view + Button { + withAnimation { + roomCtx.showDataStreamsPanel.toggle() + } + } label: { + Image(systemSymbol: .arrowUpArrowDown) + .foregroundColor(roomCtx.showDataStreamsPanel ? .accentColor : nil) + } #endif // Toggle messages view (chat example)