diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
index 3778de787..afa0f753f 100644
--- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
+++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
@@ -381,6 +381,127 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
gracefullyLeaveParticipants(user, 2);
}
+ @Test
+ @DisplayName("Data Tracks publish, subscribe, send and receive")
+ void dataTracksTest() throws Exception {
+
+ OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
+
+ log.info("Data Tracks publish, subscribe, send and receive");
+
+ // Two participants, no audio/video publishing
+ for (int i = 0; i < 2; i++) {
+ WebElement addUserBtn = user.getDriver().findElement(By.id("add-user-btn"));
+ addUserBtn.click();
+ user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .subscriber-checkbox")).click();
+ user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .publisher-checkbox")).click();
+ }
+ user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER));
+ user.getEventManager().waitUntilEventReaches("signalConnected", "RoomEvent", 2);
+ user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
+ user.getEventManager().waitUntilEventReaches("participantConnected", "RoomEvent", 1);
+ user.getEventManager().waitUntilEventReaches("participantActive", "RoomEvent", 1);
+
+ // Participant 0 publishes a data track
+ user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 .add-data-track-btn")).click();
+
+ // Wait for localDataTrackPublished on participant 0 and dataTrackPublished on participant 1
+ user.getEventManager().waitUntilEventReaches(0, "localDataTrackPublished", "RoomEvent", 1);
+ user.getEventManager().waitUntilEventReaches(1, "dataTrackPublished", "RoomEvent", 1);
+
+ // Verify event descriptions
+ user.getDriver().findElements(By.cssSelector(".localDataTrackPublished-TestParticipant0 .event-content"))
+ .forEach(el -> Assertions.assertTrue(el.getText().contains("data_track_1"),
+ "Expected localDataTrackPublished to contain track name 'data_track_1'"));
+ user.getDriver().findElements(By.cssSelector(".dataTrackPublished-TestParticipant1 .event-content"))
+ .forEach(el -> Assertions.assertTrue(
+ el.getText().contains("TestParticipant0") && el.getText().contains("data_track_1"),
+ "Expected dataTrackPublished to contain publisher identity and track name"));
+
+ // Participant 0 does not receive its own dataTrackPublished
+ Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataTrackPublished-RoomEvent").get(),
+ "Publisher should not receive dataTrackPublished for its own data track");
+
+ // Participant 0 sends data frames
+ org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
+ js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
+ js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
+ js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
+
+ // Wait for frames to arrive at participant 1
+ Thread.sleep(1000);
+ Long frameCount = (Long) js.executeScript(
+ "var el = document.querySelector('#openvidu-instance-1 .data-track-frame-count');" +
+ "return el ? parseInt(el.textContent) : 0;");
+ Assertions.assertEquals(frameCount, 3, "Expected 3 data track frames received, got " + frameCount);
+
+ String lastPayload = (String) js.executeScript(
+ "var el = document.querySelector('#openvidu-instance-1 .data-track-last-payload');" +
+ "return el ? el.textContent : '';");
+ Assertions.assertTrue(lastPayload.contains("DataTrackFrame from TestParticipant0"),
+ "Expected last payload to contain 'DataTrackFrame from TestParticipant0', got: " + lastPayload);
+
+ // Participant 0 unpublishes the data track
+ js.executeScript("document.querySelector('#openvidu-instance-0 .unpublish-data-track-btn').click()");
+
+ // Wait for unpublish events
+ user.getEventManager().waitUntilEventReaches(0, "localDataTrackUnpublished", "RoomEvent", 1);
+ user.getEventManager().waitUntilEventReaches(1, "dataTrackUnpublished", "RoomEvent", 1);
+
+ // Participant 0 does not receive its own dataTrackUnpublished
+ Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataTrackUnpublished-RoomEvent").get(),
+ "Publisher should not receive dataTrackUnpublished for its own data track");
+
+ user.getEventManager().clearAllCurrentEvents();
+
+ // Now participant 1 publishes a data track with custom name (bidirectional test)
+ String customTrackName = "my_custom_data_track";
+ user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .options-data-track-btn")).click();
+ Thread.sleep(500);
+ WebElement trackNameInput = user.getDriver().findElement(By.id("dataTrack-name"));
+ trackNameInput.clear();
+ trackNameInput.sendKeys(customTrackName);
+ user.getDriver().findElement(By.id("close-dialog-btn")).click();
+ Thread.sleep(500);
+ user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .add-data-track-btn")).click();
+
+ user.getEventManager().waitUntilEventReaches(1, "localDataTrackPublished", "RoomEvent", 1);
+ user.getEventManager().waitUntilEventReaches(0, "dataTrackPublished", "RoomEvent", 1);
+
+ // Verify participant 0 sees the custom track name on the remote data track
+ Thread.sleep(500);
+ String remoteTrackName = (String) js.executeScript(
+ "var el = document.querySelector('#openvidu-instance-0 .data-track-name');" +
+ "return el ? el.textContent.trim() : '';");
+ Assertions.assertEquals(customTrackName, remoteTrackName,
+ "Remote data track name should be '" + customTrackName + "', got: " + remoteTrackName);
+
+ // Participant 1 sends a data frame
+ js.executeScript("document.querySelector('#openvidu-instance-1 .send-data-frame-btn').click()");
+
+ // Wait for frame to arrive at participant 0
+ Thread.sleep(1000);
+
+ Long frameCount0 = (Long) js.executeScript(
+ "var el = document.querySelector('#openvidu-instance-0 .data-track-frame-count');" +
+ "return el ? parseInt(el.textContent) : 0;");
+ Assertions.assertTrue(frameCount0 >= 1,
+ "Expected at least 1 data track frame received by participant 0, got " + frameCount0);
+
+ String lastPayload0 = (String) js.executeScript(
+ "var el = document.querySelector('#openvidu-instance-0 .data-track-last-payload');" +
+ "return el ? el.textContent : '';");
+ Assertions.assertTrue(lastPayload0.contains("DataTrackFrame from TestParticipant1"),
+ "Expected last payload to contain 'DataTrackFrame from TestParticipant1', got: " + lastPayload0);
+
+ // Participant 1 unpublishes
+ js.executeScript("document.querySelector('#openvidu-instance-1 .unpublish-data-track-btn').click()");
+ user.getEventManager().waitUntilEventReaches(1, "localDataTrackUnpublished", "RoomEvent", 1);
+ user.getEventManager().waitUntilEventReaches(0, "dataTrackUnpublished", "RoomEvent", 1);
+
+ gracefullyLeaveParticipants(user, 2);
+ }
+
@Test
@DisplayName("One2One only audio")
void oneToOneOnlyAudioSession() throws Exception {
diff --git a/openvidu-testapp/src/app/components/data-track/data-track.component.css b/openvidu-testapp/src/app/components/data-track/data-track.component.css
new file mode 100644
index 000000000..4f9985e33
--- /dev/null
+++ b/openvidu-testapp/src/app/components/data-track/data-track.component.css
@@ -0,0 +1,52 @@
+.parent-div {
+ display: grid;
+ margin-bottom: 4px;
+ background-color: #fcfcfc;
+ padding: 2px;
+ border-radius: 2px;
+}
+
+.data-track-name {
+ font-size: 10px;
+}
+
+.bottom-div {
+ display: flex;
+ justify-content: space-around;
+ margin-bottom: -2px;
+}
+
+.data-btn {
+ border: none;
+ background: rgba(255, 255, 255, 0.75);
+ cursor: pointer;
+ padding: 0;
+ height: 16px;
+ float: left;
+}
+
+.data-btn:hover {
+ color: #4d4d4d;
+}
+
+.data-btn mat-icon {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ line-height: 16px;
+}
+
+.data-track-info {
+ font-size: 10px;
+ padding: 2px 0;
+}
+
+.data-track-frame-count {
+ font-weight: bold;
+ margin-right: 4px;
+}
+
+.data-track-last-payload {
+ color: #555;
+ word-break: break-all;
+}
diff --git a/openvidu-testapp/src/app/components/data-track/data-track.component.html b/openvidu-testapp/src/app/components/data-track/data-track.component.html
new file mode 100644
index 000000000..38cb29fe1
--- /dev/null
+++ b/openvidu-testapp/src/app/components/data-track/data-track.component.html
@@ -0,0 +1,25 @@
+
+
{{getTrackName()}}
+
+ @if (localParticipant && localDataTrack) {
+
+ }
+ @if (localParticipant && localDataTrack) {
+
+ }
+
+ @if (frameCount > 0) {
+
+ {{frameCount}}
+ {{lastPayload}}
+
+ }
+
diff --git a/openvidu-testapp/src/app/components/data-track/data-track.component.ts b/openvidu-testapp/src/app/components/data-track/data-track.component.ts
new file mode 100644
index 000000000..8b5cfc713
--- /dev/null
+++ b/openvidu-testapp/src/app/components/data-track/data-track.component.ts
@@ -0,0 +1,98 @@
+import { Component, EventEmitter, Input, Output, ChangeDetectorRef } from '@angular/core';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import {
+ LocalDataTrack,
+ LocalParticipant,
+ RemoteDataTrack,
+} from 'livekit-client';
+import {
+ TestAppEvent,
+ TestFeedService,
+} from 'src/app/services/test-feed.service';
+
+@Component({
+ selector: 'app-data-track',
+ templateUrl: './data-track.component.html',
+ styleUrl: './data-track.component.css',
+ imports: [MatIconModule, MatTooltipModule],
+})
+export class DataTrackComponent {
+ @Input()
+ localParticipant: LocalParticipant | undefined;
+
+ @Input()
+ index: number;
+
+ @Input()
+ localDataTrack?: LocalDataTrack;
+
+ @Input()
+ remoteDataTrack?: RemoteDataTrack;
+
+ @Output()
+ newTrackEvent = new EventEmitter();
+
+ @Output()
+ trackUnpublished = new EventEmitter();
+
+ // Received frame info
+ frameCount: number = 0;
+ lastPayload: string = '';
+
+ private decoder = new TextDecoder();
+
+ constructor(
+ private testFeedService: TestFeedService,
+ private cdr: ChangeDetectorRef
+ ) {}
+
+ ngOnInit() {
+ if (this.remoteDataTrack) {
+ this.subscribeToRemoteDataTrack(this.remoteDataTrack);
+ }
+ }
+
+ sendDataFrame() {
+ if (this.localDataTrack) {
+ const payload = new TextEncoder().encode(
+ `DataTrackFrame from ${this.localParticipant?.identity}`
+ );
+ this.localDataTrack.tryPush({ payload });
+ }
+ }
+
+ async unpublishDataTrack() {
+ if (this.localDataTrack) {
+ const track = this.localDataTrack;
+ await track.unpublish();
+ this.trackUnpublished.emit(track);
+ }
+ }
+
+ private async subscribeToRemoteDataTrack(track: RemoteDataTrack) {
+ const stream = track.subscribe();
+ const reader = stream.getReader();
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ this.frameCount++;
+ this.lastPayload = this.decoder.decode(value.payload);
+ this.cdr.detectChanges();
+ }
+ } catch (_) {
+ // Stream closed
+ }
+ }
+
+ getTrackName(): string {
+ if (this.localDataTrack?.info) {
+ return this.localDataTrack.info.name;
+ }
+ if (this.remoteDataTrack) {
+ return this.remoteDataTrack.info.name;
+ }
+ return '';
+ }
+}
diff --git a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
index 846c6770a..bb17ba6eb 100644
--- a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
+++ b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
@@ -233,6 +233,16 @@
}
+ @if (dataTrackName !== undefined) {
+
+
+
+
+ Track Name
+
+
+
+ }
diff --git a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
index 4ff7a7581..6ad7ed1b4 100644
--- a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
+++ b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
@@ -38,6 +38,8 @@ export class OptionsDialogComponent {
allowDisablingVideo = true;
allowDisablingScreen = true;
+ dataTrackName?: string;
+
videoOption: true | false | 'custom';
audioOption: true | false | 'custom';
screenOption: true | false | 'custom';
@@ -62,6 +64,7 @@ export class OptionsDialogComponent {
allowDisablingAudio?: boolean;
allowDisablingVideo?: boolean;
allowDisablingScreen?: boolean;
+ dataTrackName?: string;
}>(MAT_DIALOG_DATA);
constructor(
@@ -114,6 +117,9 @@ export class OptionsDialogComponent {
if (this.data.allowDisablingScreen === false) {
this.allowDisablingScreen = false;
}
+ if (this.data.dataTrackName !== undefined) {
+ this.dataTrackName = this.data.dataTrackName;
+ }
Room.getLocalDevices('videoinput').then((devices) => {
this.inputVideoDevices = devices;
});
@@ -152,6 +158,7 @@ export class OptionsDialogComponent {
createLocalTracksOptions: this.createLocalTracksOptions,
screenShareCaptureOptions: this.screenShareCaptureOptions,
trackPublishOptions: this.trackPublishOptions,
+ dataTrackName: this.dataTrackName,
});
}
diff --git a/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts b/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts
index d4a31a9d9..0691b0b6d 100644
--- a/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts
+++ b/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts
@@ -21,6 +21,7 @@ import {
DataPublishOptions,
DisconnectReason,
LocalAudioTrack,
+ LocalDataTrack,
LocalParticipant,
LocalTrack,
LocalTrackPublication,
@@ -28,6 +29,7 @@ import {
MediaDeviceFailure,
Participant,
RemoteAudioTrack,
+ RemoteDataTrack,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
@@ -143,7 +145,7 @@ export class OpenviduInstanceComponent {
this.participantName += this.index;
if (this.roomConf.startSession) {
const token = await this.roomApiService.createToken(
- { roomJoin: true },
+ { roomJoin: true, canPublishData: true },
this.participantName,
this.roomName
);
@@ -163,7 +165,7 @@ export class OpenviduInstanceComponent {
async createTokenAndConnectRoom() {
this.connectRoom(
await this.roomApiService.createToken(
- { roomJoin: true },
+ { roomJoin: true, canPublishData: true },
this.participantName,
this.roomName
)
@@ -1078,6 +1080,86 @@ export class OpenviduInstanceComponent {
);
}
}
+
+ if (
+ firstTime ||
+ this.roomEvents.get(RoomEvent.DataTrackPublished) !==
+ oldValues.get(RoomEvent.DataTrackPublished)
+ ) {
+ this.room?.removeAllListeners(RoomEvent.DataTrackPublished);
+ if (this.roomEvents.get(RoomEvent.DataTrackPublished)) {
+ this.room!.on(
+ RoomEvent.DataTrackPublished,
+ (track: RemoteDataTrack) => {
+ this.updateEventList(
+ RoomEvent.DataTrackPublished,
+ { name: track.info.name, sid: track.info.sid, publisherIdentity: track.publisherIdentity },
+ `${track.publisherIdentity} (${track.info.name})`
+ );
+ }
+ );
+ }
+ }
+
+ if (
+ firstTime ||
+ this.roomEvents.get(RoomEvent.DataTrackUnpublished) !==
+ oldValues.get(RoomEvent.DataTrackUnpublished)
+ ) {
+ this.room?.removeAllListeners(RoomEvent.DataTrackUnpublished);
+ if (this.roomEvents.get(RoomEvent.DataTrackUnpublished)) {
+ this.room!.on(
+ RoomEvent.DataTrackUnpublished,
+ (sid: string) => {
+ this.updateEventList(
+ RoomEvent.DataTrackUnpublished,
+ { sid },
+ `sid: ${sid}`
+ );
+ }
+ );
+ }
+ }
+
+ if (
+ firstTime ||
+ this.roomEvents.get(RoomEvent.LocalDataTrackPublished) !==
+ oldValues.get(RoomEvent.LocalDataTrackPublished)
+ ) {
+ this.room?.removeAllListeners(RoomEvent.LocalDataTrackPublished);
+ if (this.roomEvents.get(RoomEvent.LocalDataTrackPublished)) {
+ this.room!.on(
+ RoomEvent.LocalDataTrackPublished,
+ (track: LocalDataTrack) => {
+ this.updateEventList(
+ RoomEvent.LocalDataTrackPublished,
+ { name: track.info?.name, sid: track.info?.sid },
+ `${track.info?.name}`
+ );
+ }
+ );
+ }
+ }
+
+ if (
+ firstTime ||
+ this.roomEvents.get(RoomEvent.LocalDataTrackUnpublished) !==
+ oldValues.get(RoomEvent.LocalDataTrackUnpublished)
+ ) {
+ this.room?.removeAllListeners(RoomEvent.LocalDataTrackUnpublished);
+ if (this.roomEvents.get(RoomEvent.LocalDataTrackUnpublished)) {
+ this.room!.on(
+ RoomEvent.LocalDataTrackUnpublished,
+ (sid: string) => {
+ this.updateEventList(
+ RoomEvent.LocalDataTrackUnpublished,
+ { sid },
+ `sid: ${sid}`
+ );
+ }
+ );
+ }
+ }
}
updateEventList(
diff --git a/openvidu-testapp/src/app/components/participant/participant.component.html b/openvidu-testapp/src/app/components/participant/participant.component.html
index 9e88ed50c..e52368654 100644
--- a/openvidu-testapp/src/app/components/participant/participant.component.html
+++ b/openvidu-testapp/src/app/components/participant/participant.component.html
@@ -3,6 +3,18 @@
{{participant.identity}}
+ @if (participant.isLocal) {
+
+ }
+ @if (participant.isLocal) {
+
+ }
@if (participant.isLocal) {
+
+ @for (track of localDataTracks; track track.info?.sid) {
+
+
+ }
+ @for (track of remoteDataTracks; track track.info.sid) {
+
+
+ }
+
@for (trackPublication of participant.audioTrackPublications| keyvalue; track trackPublication) {
{
+ if (!!result) {
+ this.dataTrackName = result.dataTrackName;
+ }
+ });
+ }
+
+ onLocalDataTrackUnpublished(track: LocalDataTrack) {
+ this.localDataTracks = this.localDataTracks.filter((t) => t !== track);
+ this.cdr.detectChanges();
+ }
+
async addVideoTrack() {
const options =
this.createLocalTracksOptions.video === true
@@ -479,4 +516,25 @@ export class ParticipantComponent {
sendDataLossy() {
this.sendLossyDataToOneParticipant.emit(this.participant.identity);
}
+
+ private setupDataTrackListeners() {
+ this.room.on(
+ RoomEvent.DataTrackPublished,
+ (track: RemoteDataTrack) => {
+ if (track.publisherIdentity === this.participant.identity) {
+ this.remoteDataTracks.push(track);
+ this.cdr.detectChanges();
+ }
+ }
+ );
+ this.room.on(
+ RoomEvent.DataTrackUnpublished,
+ (sid: string) => {
+ this.remoteDataTracks = this.remoteDataTracks.filter(
+ (t) => t.info.sid !== sid
+ );
+ this.cdr.detectChanges();
+ }
+ );
+ }
}