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(); + } + ); + } }