Compare commits

...

17 Commits

Author SHA1 Message Date
pabloFuente
cc4f43e887 openvidu-live-captions: remove bug with participantInfo on transcriptions 2025-07-02 19:04:45 +02:00
pabloFuente
12b2c3720a Updated openvidu-live-captions 2025-06-27 17:43:53 +02:00
pabloFuente
af880945dc openvidu-live-captions: another minor beautification 2025-06-27 11:51:04 +02:00
pabloFuente
d385068049 openvidu-live-captions: minor beautification 2025-06-27 11:49:22 +02:00
pabloFuente
50a7af992a openvidu-live-captions: updated livekit-client CDN version and HTML title 2025-06-27 11:40:48 +02:00
pabloFuente
f0358d0680 openvidu-live-captions tutorial 2025-06-26 17:37:42 +02:00
Piwccle
4e90828d80 advanced-features: azure-recording-tutorials - more changes to the S3 references 2025-06-03 17:24:57 +02:00
Piwccle
75076678a5 advanced-features: azure-advanced-recording-tutorial - changed all s3 references 2025-06-03 17:04:21 +02:00
Piwccle
b1aeb2f2f2 advanced-features: azure-recording-basic-tutorial - updated package-lock.json 2025-06-03 13:26:54 +02:00
Piwccle
1c57c6e85d advanced-features: azure-advanced-tutorial - added azure advanced recording tutorial 2025-06-03 13:26:11 +02:00
Piwccle
d3180c1f9f advanced-features: recording-basic-azure - changed some comments with s3 references to azure references 2025-06-03 12:01:12 +02:00
Piwccle
9cdb39a53b advanced-features: recording-basic-azure - updated name of the service 2025-06-03 11:09:07 +02:00
Piwccle
d5dd493a37 advanced-features: recording-basic-azure - updated service name and deleted some lines of .env refered to s3 2025-06-03 11:06:54 +02:00
Piwccle
8841cd2aed advanced-features: added basic recording tutorial for azure blob storage 2025-06-02 11:47:41 +02:00
juancarmore
665681174c Update S3_BUCKET to use 'openvidu-appdata' as default value in configuration files 2025-05-29 20:30:53 +02:00
pabloFuente
966ba75a8a Update dependencies of python tutorial 2025-03-05 17:45:44 +01:00
juancarmore
3228a50708 Fix comment for S3 recording deletion 2025-02-19 11:56:17 +01:00
44 changed files with 6812 additions and 5 deletions

View File

@ -0,0 +1,15 @@
SERVER_PORT=6080
# LiveKit configuration
LIVEKIT_URL=http://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# Recording configuration
RECORDINGS_PATH=recordings/
RECORDING_PLAYBACK_STRATEGY=AZURE
# Azure Blob Storage configuration
AZURE_ACCOUNT_NAME=your_account_name
AZURE_ACCOUNT_KEY=your_account_key
AZURE_CONTAINER_NAME=openvidu-appdata

View File

@ -0,0 +1,129 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

View File

@ -0,0 +1,30 @@
# OpenVidu Recording Advanced Node
Simple video-call application with recording capabilities (advanced version). It includes a backend built with Node.js with Express and a frontend built with plain HTML, CSS and JavaScript.
For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/advanced-features/recording-advanced/).
## Prerequisites
- [Node](https://nodejs.org/en/download)
## Run
1. Download repository
```bash
git clone https://github.com/OpenVidu/openvidu-livekit-tutorials.git
cd openvidu-livekit-tutorials/advanced-features/openvidu-recording-advanced-node
```
2. Install dependencies
```bash
npm install
```
3. Run the application
```bash
npm start
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "openvidu-recording-advanced-node",
"version": "1.0.0",
"description": "Simple video-call application with recording capabilities (advanced version)",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@azure/storage-blob": "12.27.0",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.0",
"livekit-server-sdk": "^2.7.2"
}
}

View File

@ -0,0 +1,432 @@
// When running OpenVidu locally, leave this variable empty
// For other deployment type, configure it with correct URL depending on your deployment
var LIVEKIT_URL = "";
configureLiveKitUrl();
const LivekitClient = window.LivekitClient;
var room;
function configureLiveKitUrl() {
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
if (!LIVEKIT_URL) {
if (window.location.hostname === "localhost") {
LIVEKIT_URL = "ws://localhost:7880/";
} else {
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
}
}
}
async function joinRoom() {
// Disable 'Join' button
document.getElementById("join-button").disabled = true;
document.getElementById("join-button").innerText = "Joining...";
// Initialize a new Room object
room = new LivekitClient.Room();
// Specify the actions when events take place in the room
// On every new Track received...
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _publication, participant) => {
addTrack(track, participant.identity);
});
// On every new Track destroyed...
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track, _publication, participant) => {
track.detach();
document.getElementById(track.sid)?.remove();
if (track.kind === "video") {
removeVideoContainer(participant.identity);
}
});
// When recording status changes...
room.on(LivekitClient.RoomEvent.RoomMetadataChanged, async (metadata) => {
const { recordingStatus } = JSON.parse(metadata);
await updateRecordingInfo(recordingStatus);
});
// When a message is received...
room.on(LivekitClient.RoomEvent.DataReceived, async (payload, _participant, _kind, topic) => {
// If the message is a recording deletion notification, remove the recording from the list
if (topic === "RECORDING_DELETED") {
const { recordingName } = JSON.parse(new TextDecoder().decode(payload));
deleteRecordingContainer(recordingName);
}
});
try {
// Get the room name and participant name from the form
const roomName = document.getElementById("room-name").value;
const userName = document.getElementById("participant-name").value;
// Get a token from your application server with the room name and participant name
const token = await getToken(roomName, userName);
// Connect to the room with the LiveKit URL and the token
await room.connect(LIVEKIT_URL, token);
// Hide the 'Join room' page and show the 'Room' page
document.getElementById("room-title").innerText = roomName;
document.getElementById("join").hidden = true;
document.getElementById("room").hidden = false;
// Publish your camera and microphone
await room.localParticipant.enableCameraAndMicrophone();
const localVideoTrack = this.room.localParticipant.videoTrackPublications.values().next().value.track;
addTrack(localVideoTrack, userName, true);
// Update recording info
const { recordingStatus } = JSON.parse(room.metadata);
await updateRecordingInfo(recordingStatus);
if (recordingStatus !== "STOPPED" && recordingStatus !== "FAILED") {
const roomId = await room.getSid();
await listRecordings(room.name, roomId);
}
} catch (error) {
console.log("There was an error connecting to the room:", error.message);
await leaveRoom();
}
}
function addTrack(track, participantIdentity, local = false) {
const element = track.attach();
element.id = track.sid;
/* If the track is a video track, we create a container and append the video element to it
with the participant's identity */
if (track.kind === "video") {
const videoContainer = createVideoContainer(participantIdentity, local);
videoContainer.append(element);
appendParticipantData(videoContainer, participantIdentity + (local ? " (You)" : ""));
} else {
document.getElementById("layout-container").append(element);
}
}
async function leaveRoom() {
// Leave the room by calling 'disconnect' method over the Room object
await room.disconnect();
// Remove all HTML elements inside the layout container
removeAllLayoutElements();
// Remove all recordings from the list
removeAllRecordings();
// Reset recording state
document.getElementById("recording-button").disabled = false;
document.getElementById("recording-button").innerText = "Start Recording";
document.getElementById("recording-button").className = "btn btn-primary";
document.getElementById("recording-text").hidden = true;
// Back to 'Join room' page
document.getElementById("join").hidden = false;
document.getElementById("room").hidden = true;
// Enable 'Join' button
document.getElementById("join-button").disabled = false;
document.getElementById("join-button").innerText = "Join!";
}
window.onbeforeunload = () => {
room?.disconnect();
};
document.addEventListener("DOMContentLoaded", async function () {
var currentPage = window.location.pathname;
if (currentPage === "/recordings.html") {
await listRecordings();
} else {
generateFormValues();
}
// Remove recording video when the dialog is closed
document.getElementById("recording-video-dialog").addEventListener("close", () => {
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = "";
});
});
function generateFormValues() {
document.getElementById("room-name").value = "Test Room";
document.getElementById("participant-name").value = "Participant" + Math.floor(Math.random() * 100);
}
function createVideoContainer(participantIdentity, local = false) {
const videoContainer = document.createElement("div");
videoContainer.id = `camera-${participantIdentity}`;
videoContainer.className = "video-container";
const layoutContainer = document.getElementById("layout-container");
if (local) {
layoutContainer.prepend(videoContainer);
} else {
layoutContainer.append(videoContainer);
}
return videoContainer;
}
function appendParticipantData(videoContainer, participantIdentity) {
const dataElement = document.createElement("div");
dataElement.className = "participant-data";
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
videoContainer.prepend(dataElement);
}
function removeVideoContainer(participantIdentity) {
const videoContainer = document.getElementById(`camera-${participantIdentity}`);
videoContainer?.remove();
}
function removeAllLayoutElements() {
const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => {
element.remove();
});
}
/**
* --------------------------------------------
* GETTING A TOKEN FROM YOUR APPLICATION SERVER
* --------------------------------------------
* The method below request the creation of a token to
* your application server. This prevents the need to expose
* your LiveKit API key and secret to the client side.
*
* In this sample code, there is no user control at all. Anybody could
* access your application server endpoints. In a real production
* environment, your application server must identify the user to allow
* access to the endpoints.
*/
async function getToken(roomName, participantName) {
const [error, body] = await httpRequest("POST", "/token", {
roomName,
participantName
});
if (error) {
throw new Error(`Failed to get token: ${error.message}`);
}
return body.token;
}
async function updateRecordingInfo(recordingStatus) {
const recordingButton = document.getElementById("recording-button");
const recordingText = document.getElementById("recording-text");
switch (recordingStatus) {
case "STARTING":
recordingButton.disabled = true;
recordingButton.innerText = "Starting...";
break;
case "STARTED":
recordingButton.disabled = false;
recordingButton.innerText = "Stop Recording";
recordingButton.className = "btn btn-danger";
recordingText.hidden = false;
break;
case "STOPPING":
recordingButton.disabled = true;
recordingButton.innerText = "Stopping...";
recordingButton.className = "btn btn-danger";
recordingText.hidden = false;
break;
case "STOPPED":
case "FAILED":
recordingButton.disabled = false;
recordingButton.innerText = "Start Recording";
recordingButton.className = "btn btn-primary";
recordingText.hidden = true;
const roomId = await room.getSid();
await listRecordings(room.name, roomId);
break;
}
}
async function manageRecording() {
const recordingButton = document.getElementById("recording-button");
if (recordingButton.innerText === "Start Recording") {
await startRecording();
} else {
await stopRecording();
}
}
async function startRecording() {
return httpRequest("POST", "/recordings/start", {
roomName: room.name
});
}
async function stopRecording() {
return httpRequest("POST", "/recordings/stop", {
roomName: room.name
});
}
async function deleteRecording(recordingName) {
const [error, _] = await httpRequest("DELETE", `/recordings/${recordingName}`);
if (!error || error.status === 404) {
deleteRecordingContainer(recordingName);
}
}
function deleteRecordingContainer(recordingName) {
const recordingContainer = document.getElementById(recordingName);
if (recordingContainer) {
recordingContainer.remove();
const recordingsList = document.getElementById("recording-list");
if (recordingsList.children.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>";
}
}
}
async function listRecordings(roomName, roomId) {
const url = "/recordings" + (roomName ? `?roomName=${roomName}` + (roomId ? `&roomId=${roomId}` : "") : "");
const [error, body] = await httpRequest("GET", url);
if (!error) {
const recordings = body.recordings;
showRecordingList(recordings);
}
}
async function listRecordingsByRoom() {
const roomName = document.getElementById("room-name").value;
await listRecordings(roomName);
}
async function getRecordingUrl(recordingName) {
const [_, body] = await httpRequest("GET", `/recordings/${recordingName}/url`);
return body?.recordingUrl;
}
async function httpRequest(method, url, body) {
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json"
},
body: method !== "GET" ? JSON.stringify(body) : undefined
});
const responseBody = await response.json();
if (!response.ok) {
console.error(responseBody.errorMessage);
const error = {
status: response.status,
message: responseBody.errorMessage
};
return [error, undefined];
}
return [undefined, responseBody];
} catch (error) {
console.error(error.message);
const errorObj = {
status: 0,
message: error.message
};
return [errorObj, undefined];
}
}
function showRecordingList(recordings) {
const recordingsList = document.getElementById("recording-list");
if (recordings.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>";
} else {
recordingsList.innerHTML = "";
}
recordings.forEach((recording) => {
const recordingName = recording.name;
const recordingDuration = secondsToHms(recording.duration ?? 0);
const recordingSize = formatBytes(recording.size ?? 0);
const recordingDate = new Date(recording.startedAt).toLocaleString();
const recordingContainer = document.createElement("div");
recordingContainer.className = "recording-container";
recordingContainer.id = recordingName;
recordingContainer.innerHTML = `
<i class="fa-solid fa-file-video"></i>
<div class="recording-info">
<p class="recording-name">${recordingName}</p>
<p class="recording-size">${recordingDuration} | ${recordingSize}</p>
<p class="recording-date">${recordingDate}</p>
</div>
<div class="recording-actions">
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
<i class="fa-solid fa-play"></i>
</button>
<button title="Delete" class="icon-button delete-button" onclick="deleteRecording('${recordingName}')">
<i class="fa-solid fa-trash"></i>
</button>
</div>
`;
recordingsList.append(recordingContainer);
});
}
async function displayRecording(recordingName) {
const recordingVideoDialog = document.getElementById("recording-video-dialog");
recordingVideoDialog.showModal();
const recordingVideo = document.getElementById("recording-video");
const recordingUrl = await getRecordingUrl(recordingName);
recordingVideo.src = recordingUrl;
}
function closeRecording() {
const recordingVideoDialog = document.getElementById("recording-video-dialog");
recordingVideoDialog.close();
}
function removeAllRecordings() {
const recordingList = document.getElementById("recording-list").children;
Array.from(recordingList).forEach((recording) => {
recording.remove();
});
}
function secondsToHms(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor((seconds % 3600) % 60);
const hDisplay = h > 0 ? h + "h " : "";
const mDisplay = m > 0 ? m + "m " : "";
const sDisplay = s + "s";
return hDisplay + mDisplay + sDisplay;
}
function formatBytes(bytes) {
if (bytes === 0) {
return "0Bytes";
}
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const decimals = i < 2 ? 0 : 1;
return (bytes / Math.pow(k, i)).toFixed(decimals) + sizes[i];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Recording</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
title="Documentation"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
</div>
</header>
<main>
<div id="join">
<div id="join-dialog">
<h2>Join a Video Room</h2>
<form onsubmit="joinRoom(); return false">
<div>
<label for="participant-name">Participant</label>
<input id="participant-name" class="form-control" type="text" required />
</div>
<div>
<label for="room-name">Room</label>
<input id="room-name" class="form-control" type="text" required />
</div>
<button id="join-button" class="btn btn-lg btn-success" type="submit">Join!</button>
</form>
</div>
<a href="/recordings.html" class="btn btn-primary">View all recordings</a>
</div>
<div id="room" hidden>
<div id="recording-text" hidden><span>Room is being recorded</span></div>
<div id="room-header">
<h2 id="room-title"></h2>
<button class="btn btn-primary" id="recording-button" onclick="manageRecording()">
Start Recording
</button>
<button class="btn btn-danger" id="leave-room-button" onclick="leaveRoom()">Leave Room</button>
</div>
<div id="layout-container"></div>
<div id="recordings">
<div id="recording-header">
<h3>Session Recordings</h3>
<a href="/recordings.html" target="_blank" class="btn btn-sm btn-primary"
>View all recordings</a
>
</div>
<div id="recording-list"></div>
<dialog id="recording-video-dialog">
<video id="recording-video" autoplay controls></video>
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
Close
</button>
</dialog>
</div>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="https://openvidu.io/" target="_blank">
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Recording</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
title="Documentation"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
</div>
</header>
<main>
<div id="recordings-all">
<div id="actions">
<button id="refresh-button" title="Refresh" class="icon-button" onclick="listRecordings()">
<i class="fas fa-sync-alt"></i>
</button>
<form onsubmit="listRecordingsByRoom(); return false">
<input id="room-name" type="text" placeholder="Room name" />
<button title="Search" type="submit">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<h2>Recordings</h2>
<div id="recording-list"></div>
<dialog id="recording-video-dialog">
<video id="recording-video" autoplay controls></video>
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
Close
</button>
</dialog>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="https://openvidu.io/" target="_blank">
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

View File

@ -0,0 +1,479 @@
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
padding-top: 50px;
display: flex;
flex-direction: column;
height: 100%;
}
header {
height: 50px;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 30px;
background-color: #4d4d4d;
}
header h1 {
margin: 0;
font-size: 1.5em;
font-weight: bold;
}
header a {
color: #ccc;
text-decoration: none;
}
header a:hover {
color: #a9a9a9;
}
header i {
padding: 5px 5px;
font-size: 2em;
}
main {
flex: 1;
padding: 20px;
}
#join {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
height: 100%;
}
#join-dialog {
width: 70%;
max-width: 900px;
padding: 60px;
border-radius: 6px;
background-color: #f0f0f0;
}
#join-dialog h2 {
color: #4d4d4d;
font-size: 60px;
font-weight: bold;
text-align: center;
}
#join-dialog form {
text-align: left;
}
#join-dialog label {
display: block;
margin-bottom: 10px;
color: #0088aa;
font-weight: bold;
font-size: 20px;
}
.form-control {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
color: #0088aa;
font-weight: bold;
}
.form-control:focus {
color: #0088aa;
border-color: #0088aa;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
}
#join-dialog button {
display: block;
margin: 20px auto 0;
}
.btn {
font-weight: bold;
}
.btn-success {
background-color: #06d362;
border-color: #06d362;
}
.btn-success:hover {
background-color: #1abd61;
border-color: #1abd61;
}
#room {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#recording-text {
width: 100%;
max-width: 1000px;
}
#recording-text span {
background-color: #ffeb3b;
color: #333;
border-radius: 5px;
padding: 0 5px;
font-weight: bold;
font-style: italic;
float: right;
margin-bottom: 10px;
}
#room-header {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 1000px;
padding: 0 20px;
margin-bottom: 20px;
}
#room-title {
font-size: 2em;
font-weight: bold;
margin: 0;
}
#layout-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
height: 100%;
}
.video-container {
position: relative;
background: #3b3b3b;
aspect-ratio: 16/9;
border-radius: 6px;
overflow: hidden;
}
.video-container video {
width: 100%;
height: 100%;
}
.video-container .participant-data {
position: absolute;
top: 0;
left: 0;
}
.participant-data p {
background: #f8f8f8;
margin: 0;
padding: 0 5px;
color: #777777;
font-weight: bold;
border-bottom-right-radius: 4px;
}
#recordings {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
}
#recording-header {
margin: 15px 0;
width: 100%;
display: grid;
grid-template-columns: 1fr 3fr 1fr;
align-items: center;
gap: 10px;
}
#recording-header h3 {
margin: 0;
text-align: center;
font-size: 30px;
font-weight: bold;
color: #444;
grid-column-start: 2;
}
#recording-header a {
margin-left: auto;
}
#recording-list {
width: 600px;
max-width: 100%;
}
#recording-list span {
font-size: 16px;
font-weight: bold;
font-style: italic;
color: #333;
display: block;
margin: 0;
text-align: center;
}
.recording-container {
display: flex;
align-items: center;
border: 1px solid #dcdcdc;
border-radius: 5px;
padding: 10px;
background-color: #f4f4f4;
margin: 10px;
}
.fa-file-video {
font-size: 30px;
color: #333;
margin-right: 10px;
}
.recording-info {
flex-grow: 1;
font-size: 14px;
color: #333;
}
.recording-info p {
margin: 0;
}
.recording-name {
font-weight: bold;
}
.recording-size,
.recording-date {
color: #777;
}
.recording-date {
font-style: italic;
}
.recording-actions {
display: flex;
align-items: center;
}
.icon-button {
background-color: transparent;
border: none;
color: #333;
margin-left: 10px;
padding: 4px 12px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 20px;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.icon-button:hover {
background-color: #e0e0e0;
}
.delete-button {
color: #d9534f;
}
#recording-video-dialog {
width: 500px;
max-width: 90%;
border: none;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
padding: 20px;
background-color: #fff;
}
#recording-video {
width: 100%;
border-radius: 5px;
}
#close-recording-video-dialog {
margin-top: 10px;
float: right;
}
footer {
height: 60px;
width: 100%;
padding: 10px 30px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #4d4d4d;
}
footer a {
color: #ffffff;
text-decoration: none;
}
footer .text {
color: #ccc;
margin: 0;
}
footer .text span {
color: white;
font-weight: bold;
}
#openvidu-logo {
height: 35px;
-webkit-transition: all 0.1s ease-in-out;
-moz-transition: all 0.1s ease-in-out;
-o-transition: all 0.1s ease-in-out;
transition: all 0.1s ease-in-out;
}
#openvidu-logo:hover {
-webkit-filter: grayscale(0.5);
filter: grayscale(0.5);
}
/* Media Queries */
@media screen and (max-width: 768px) {
header {
padding: 10px 15px;
}
#join-dialog {
width: 90%;
padding: 30px;
}
#join-dialog h2 {
font-size: 50px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
footer {
padding: 10px 15px;
}
}
@media screen and (max-width: 480px) {
header {
padding: 10px;
}
#join-dialog {
width: 100%;
padding: 20px;
}
#join-dialog h2 {
font-size: 40px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.video-container {
aspect-ratio: 9/16;
}
footer {
padding: 10px;
}
}
/* Recordings page styles */
#actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
width: 100%;
max-width: 1000px;
margin-bottom: 20px;
}
#refresh-button {
margin: 0;
padding: 8px 13px;
}
form input {
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
form button {
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-left: none;
background-color: #007bff;
color: #fff;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
.search-form button:hover {
background-color: #0056b3;
}
#recordings-all {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#recordings-all h2 {
width: 100%;
max-width: 1000px;
margin-bottom: 20px;
font-size: 40px;
font-weight: bold;
text-align: center;
color: #444;
}

View File

@ -0,0 +1,18 @@
export const SERVER_PORT = process.env.SERVER_PORT || 6080;
export const APP_NAME = "openvidu-recording-advanced-node";
// LiveKit configuration
export const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
export const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
export const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
// Azure Blob Storage configuration
export const AZURE_ACCOUNT_NAME = process.env.AZURE_ACCOUNT_NAME || "your_account_name";
export const AZURE_ACCOUNT_KEY = process.env.AZURE_ACCOUNT_KEY || "your_account_key";
export const AZURE_CONTAINER_NAME = process.env.AZURE_CONTAINER_NAME || "openvidu-appdata";
export const AZURE_ENDPOINT = process.env.AZURE_ENDPOINT || `https://${AZURE_ACCOUNT_NAME}.blob.core.windows.net`;
export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
export const RECORDINGS_METADATA_PATH = ".metadata/";
export const RECORDING_PLAYBACK_STRATEGY = process.env.RECORDING_PLAYBACK_STRATEGY || "AZURE"; // PROXY or S3
export const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -0,0 +1,155 @@
import { Router } from "express";
import { RecordingService } from "../services/recording.service.js";
import { RoomService } from "../services/room.service.js";
import { RECORDING_PLAYBACK_STRATEGY } from "../config.js";
const recordingService = new RecordingService();
const roomService = new RoomService();
export const recordingController = Router();
recordingController.post("/start", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" });
return;
}
const activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is already an active recording for this room
if (activeRecording) {
res.status(409).json({ errorMessage: "Recording already started for this room" });
return;
}
try {
const recording = recordingService.startRecording(roomName);
res.json({ message: "Recording started", recording });
} catch (error) {
console.error("Error starting recording.", error);
res.status(500).json({ errorMessage: "Error starting recording" });
}
});
recordingController.post("/stop", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" });
return;
}
const activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is an active recording for this room
if (!activeRecording) {
res.status(409).json({ errorMessage: "Recording not started for this room" });
return;
}
try {
const recording = await recordingService.stopRecording(activeRecording);
res.json({ message: "Recording stopped", recording });
} catch (error) {
console.error("Error stopping recording.", error);
res.status(500).json({ errorMessage: "Error stopping recording" });
}
});
recordingController.get("/", async (req, res) => {
const roomName = req.query.roomName?.toString();
const roomId = req.query.roomId?.toString();
try {
const recordings = await recordingService.listRecordings(roomName, roomId);
res.json({ recordings });
} catch (error) {
console.error("Error listing recordings.", error);
res.status(500).json({ errorMessage: "Error listing recordings" });
}
});
recordingController.get("/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const { range } = req.headers;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
try {
// Get the recording file from Azure Blob Storage
const { stream, size, start, end } = await recordingService.getRecordingStream(recordingName, range);
// Set response headers
res.status(206);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "video/mp4");
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
res.setHeader("Content-Length", end - start + 1);
// Pipe the recording file to the response
stream.pipe(res).on("finish", () => res.end());
} catch (error) {
console.error("Error getting recording.", error);
res.status(500).json({ errorMessage: "Error getting recording" });
}
});
recordingController.get("/:recordingName/url", async (req, res) => {
console.log("Getting recording URL...");
const { recordingName } = req.params;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
// If the recording playback strategy is "PROXY", return the endpoint URL
if (RECORDING_PLAYBACK_STRATEGY === "PROXY") {
res.json({ recordingUrl: `/recordings/${recordingName}` });
return;
}
try {
// If the recording playback strategy is "Azure Blob Storage", return a signed URL to access the recording directly from Azure Blob Storage
const recordingUrl = await recordingService.getRecordingUrl(recordingName);
res.json({ recordingUrl });
} catch (error) {
console.error("Error getting recording URL.", error);
res.status(500).json({ errorMessage: "Error getting recording URL" });
}
});
recordingController.delete("/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
try {
const { roomName } = await recordingService.getRecordingMetadata(recordingName);
await recordingService.deleteRecording(recordingName);
// Notify to all participants that the recording was deleted
const existsRoom = await roomService.exists(roomName);
if (existsRoom) {
await roomService.sendDataToRoom(roomName, { recordingName });
}
res.json({ message: "Recording deleted" });
} catch (error) {
console.error("Error deleting recording.", error);
res.status(500).json({ errorMessage: "Error deleting recording" });
}
});

View File

@ -0,0 +1,38 @@
import { Router } from "express";
import { AccessToken } from "livekit-server-sdk";
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET } from "../config.js";
import { RoomService } from "../services/room.service.js";
const roomService = new RoomService();
export const roomController = Router();
roomController.post("/", async (req, res) => {
const roomName = req.body.roomName;
const participantName = req.body.participantName;
if (!roomName || !participantName) {
res.status(400).json({ errorMessage: "roomName and participantName are required" });
return;
}
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName
});
at.addGrant({ room: roomName, roomJoin: true, roomRecord: true });
const token = await at.toJwt();
try {
// Create room if it doesn't exist
const exists = await roomService.exists(roomName);
if (!exists) {
await roomService.createRoom(roomName);
}
res.json({ token });
} catch (error) {
console.error("Error creating room.", error);
res.status(500).json({ errorMessage: "Error creating room" });
}
});

View File

@ -0,0 +1,76 @@
import express, { Router } from "express";
import { WebhookReceiver } from "livekit-server-sdk";
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, APP_NAME } from "../config.js";
import { RoomService } from "../services/room.service.js";
import { RecordingService } from "../services/recording.service.js";
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const roomService = new RoomService();
const recordingService = new RecordingService();
export const webhookController = Router();
webhookController.use(express.raw({ type: "application/webhook+json" }));
webhookController.post("/", async (req, res) => {
try {
const webhookEvent = await webhookReceiver.receive(req.body, req.get("Authorization"));
const isWebhookRelatedToMe = await checkWebhookRelatedToMe(webhookEvent);
if (isWebhookRelatedToMe) {
console.log(webhookEvent);
const { event: eventType, egressInfo } = webhookEvent;
switch (eventType) {
case "egress_started":
case "egress_updated":
await notifyRecordingStatusUpdate(egressInfo);
break;
case "egress_ended":
await handleEgressEnded(egressInfo);
break;
}
}
} catch (error) {
console.error("Error validating webhook event.", error);
}
res.status(200).send();
});
const checkWebhookRelatedToMe = async (webhookEvent) => {
const { room, egressInfo, ingressInfo } = webhookEvent;
let roomInfo = room;
if (!room || !room.metadata) {
const roomName = room?.name ?? egressInfo?.roomName ?? ingressInfo?.roomName;
roomInfo = await roomService.getRoom(roomName);
if (!roomInfo) {
return false;
}
}
const metadata = roomInfo.metadata ? JSON.parse(roomInfo.metadata) : null;
return metadata?.createdBy === APP_NAME;
};
const handleEgressEnded = async (egressInfo) => {
try {
await recordingService.saveRecordingMetadata(egressInfo);
} catch (error) {
console.error("Error saving recording metadata.", error);
}
await notifyRecordingStatusUpdate(egressInfo);
};
const notifyRecordingStatusUpdate = async (egressInfo) => {
const roomName = egressInfo.roomName;
const recordingStatus = recordingService.getRecordingStatus(egressInfo.status);
try {
await roomService.updateRoomMetadata(roomName, recordingStatus);
} catch (error) {
console.error("Error updating room metadata.", error);
}
};

View File

@ -0,0 +1,27 @@
import "dotenv/config.js";
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import { SERVER_PORT } from "./config.js";
import { roomController } from "./controllers/room.controller.js";
import { recordingController } from "./controllers/recording.controller.js";
import { webhookController } from "./controllers/webhook.controller.js";
const app = express();
app.use(cors());
app.use(express.json());
// Set the static files location
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, "../public")));
app.use("/token", roomController);
app.use("/recordings", recordingController);
app.use("/livekit/webhook", webhookController);
app.listen(SERVER_PORT, () => {
console.log("Server started on port:", SERVER_PORT);
});

View File

@ -0,0 +1,147 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
generateBlobSASQueryParameters,
BlobSASPermissions,
SASProtocol
} from "@azure/storage-blob";
import {
AZURE_CONTAINER_NAME,
AZURE_ACCOUNT_NAME,
AZURE_ACCOUNT_KEY,
AZURE_ENDPOINT
} from "../config.js";
export class AzureBlobService {
static instance;
constructor() {
if (AzureBlobService.instance) {
return AzureBlobService.instance;
}
const sharedKeyCredential = new StorageSharedKeyCredential(
AZURE_ACCOUNT_NAME,
AZURE_ACCOUNT_KEY
);
this.blobServiceClient = new BlobServiceClient(
AZURE_ENDPOINT,
sharedKeyCredential
);
this.containerClient = this.blobServiceClient.getContainerClient(
AZURE_CONTAINER_NAME
);
AzureBlobService.instance = this;
return this;
}
static getInstance() {
if (!AzureBlobService.instance) {
AzureBlobService.instance = new AzureBlobService();
}
return AzureBlobService.instance;
}
// Uploads an object (JSON) to the container
async uploadObject(key, body) {
const blockBlobClient = this.containerClient.getBlockBlobClient(key);
const data = JSON.stringify(body);
await blockBlobClient.upload(data, Buffer.byteLength(data));
}
// Checks if a blob exists
async exists(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.exists();
}
// Gets the blob properties (equivalent to headObject)
async headObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.getProperties();
}
// Returns the blob size in bytes
async getObjectSize(key) {
const props = await this.headObject(key);
return props.contentLength || 0;
}
// Downloads the complete blob or a range, returning the stream
async getObject(key, range) {
const blobClient = this.containerClient.getBlobClient(key);
let downloadResponse;
if (range) {
// range = { start: number, end: number }
const count = range.end - range.start + 1;
downloadResponse = await blobClient.download(range.start, count);
} else {
downloadResponse = await blobClient.download();
}
if (!downloadResponse.readableStreamBody) {
throw new Error("Could not obtain the blob stream");
}
return downloadResponse.readableStreamBody;
}
// Generates a valid SAS URL for 24 hours
async getObjectUrl(key) {
if (!AZURE_ACCOUNT_NAME || !AZURE_ACCOUNT_KEY) {
throw new Error("Azure account credentials are not defined to generate SAS");
}
const blobClient = this.containerClient.getBlobClient(key);
const expiresOn = new Date(new Date().valueOf() + 24 * 60 * 60 * 1000); // 24 hours
const sasPermissions = BlobSASPermissions.parse("r");
const sasToken = generateBlobSASQueryParameters(
{
containerName: AZURE_CONTAINER_NAME,
blobName: key,
expiresOn,
permissions: sasPermissions,
protocol: SASProtocol.Https
},
new StorageSharedKeyCredential(AZURE_ACCOUNT_NAME, AZURE_ACCOUNT_KEY)
).toString();
return `${blobClient.url}?${sasToken}`;
}
// Gets the JSON content of the blob
async getObjectAsJson(key) {
const exists = await this.exists(key);
if (!exists) {
return undefined;
}
const stream = await this.getObject(key);
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (data) => {
chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data));
});
stream.on("end", () => {
const content = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(content));
});
stream.on("error", reject);
});
}
// Lists blobs under a prefix and filters by regular expression
async listObjects(prefix, regex) {
const results = [];
for await (const blob of this.containerClient.listBlobsFlat({ prefix })) {
if (blob.name && regex.test(blob.name)) {
results.push(blob.name);
}
}
return results;
}
// Deletes a blob
async deleteObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
await blobClient.delete();
}
}

View File

@ -0,0 +1,157 @@
import { EgressClient, EgressStatus, EncodedFileOutput, EncodedFileType } from "livekit-server-sdk";
import {
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
RECORDINGS_PATH,
RECORDINGS_METADATA_PATH,
RECORDING_FILE_PORTION_SIZE
} from "../config.js";
import { AzureBlobService } from "./azure.blobstorage.service.js";
const azureBlobService = new AzureBlobService();
export class RecordingService {
static instance;
constructor() {
if (RecordingService.instance) {
return RecordingService.instance;
}
this.egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
RecordingService.instance = this;
return this;
}
async startRecording(roomName) {
// Use the EncodedFileOutput to save the recording to an MP4 file
const fileOutput = new EncodedFileOutput({
fileType: EncodedFileType.MP4,
filepath: `${RECORDINGS_PATH}{room_name}-{room_id}-{time}`,
disableManifest: true
});
// Start a RoomCompositeEgress to record all participants in the room
const egressInfo = await this.egressClient.startRoomCompositeEgress(roomName, { file: fileOutput });
return this.convertToRecordingInfo(egressInfo);
}
async stopRecording(recordingId) {
// Stop the egress to finish the recording
const egressInfo = await this.egressClient.stopEgress(recordingId);
return this.convertToRecordingInfo(egressInfo);
}
async listRecordings(roomName, roomId) {
const keyStart =
RECORDINGS_PATH + RECORDINGS_METADATA_PATH + (roomName ? `${roomName}-` + (roomId ? roomId : "") : "");
const keyEnd = ".json";
const regex = new RegExp(`^${keyStart}.*${keyEnd}$`);
// List all egress metadata files in the recordings path that match the regex
const metadataKeys = await azureBlobService.listObjects(RECORDINGS_PATH + RECORDINGS_METADATA_PATH, regex);
const recordings = await Promise.all(metadataKeys.map((metadataKey) => azureBlobService.getObjectAsJson(metadataKey)));
return this.filterAndSortRecordings(recordings, roomName, roomId);
}
filterAndSortRecordings(recordings, roomName, roomId) {
let filteredRecordings = recordings;
if (roomName || roomId) {
filteredRecordings = recordings.filter((recording) => {
return (!roomName || recording.roomName === roomName) && (!roomId || recording.roomId === roomId);
});
}
return filteredRecordings.sort((a, b) => b.startedAt - a.startedAt);
}
async getActiveRecordingByRoom(roomName) {
try {
// List all active egresses for the room
const egresses = await this.egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
return null;
}
}
async getRecordingMetadata(recordingName) {
const key = this.getMetadataKey(recordingName);
return azureBlobService.getObjectAsJson(key);
}
async getRecordingStream(recordingName, range) {
const key = this.getRecordingKey(recordingName);
const size = await azureBlobService.getObjectSize(key);
// Get the requested range
const parts = range?.replace(/bytes=/, "").split("-");
const start = range ? parseInt(parts[0], 10) : 0;
const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE;
const end = Math.min(endRange, size - 1);
const stream = await azureBlobService.getObject(key, { start, end });
return { stream, size, start, end };
}
async getRecordingUrl(recordingName) {
const key = this.getRecordingKey(recordingName);
return azureBlobService.getObjectUrl(key);
}
async existsRecording(recordingName) {
const key = this.getRecordingKey(recordingName);
return azureBlobService.exists(key);
}
async deleteRecording(recordingName) {
const recordingKey = this.getRecordingKey(recordingName);
const metadataKey = this.getMetadataKey(recordingName);
// Delete the recording file and metadata file from Azure
await Promise.all([azureBlobService.deleteObject(recordingKey), azureBlobService.deleteObject(metadataKey)]);
}
async saveRecordingMetadata(egressInfo) {
const recordingInfo = this.convertToRecordingInfo(egressInfo);
const key = this.getMetadataKey(recordingInfo.name);
await azureBlobService.uploadObject(key, recordingInfo);
}
convertToRecordingInfo(egressInfo) {
const file = egressInfo.fileResults[0];
return {
id: egressInfo.egressId,
name: file.filename.split("/").pop(),
roomName: egressInfo.roomName,
roomId: egressInfo.roomId,
startedAt: Number(egressInfo.startedAt) / 1_000_000,
duration: Number(file.duration) / 1_000_000_000,
size: Number(file.size)
};
}
getRecordingStatus(egressStatus) {
switch (egressStatus) {
case EgressStatus.EGRESS_STARTING:
return "STARTING";
case EgressStatus.EGRESS_ACTIVE:
return "STARTED";
case EgressStatus.EGRESS_ENDING:
return "STOPPING";
case EgressStatus.EGRESS_COMPLETE:
return "STOPPED";
default:
return "FAILED";
}
}
getRecordingKey(recordingName) {
return RECORDINGS_PATH + recordingName;
}
getMetadataKey(recordingName) {
return RECORDINGS_PATH + RECORDINGS_METADATA_PATH + recordingName.replace(".mp4", ".json");
}
}

View File

@ -0,0 +1,61 @@
import { DataPacket_Kind, RoomServiceClient } from "livekit-server-sdk";
import { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, APP_NAME } from "../config.js";
const encoder = new TextEncoder();
export class RoomService {
static instance;
constructor() {
if (RoomService.instance) {
return RoomService.instance;
}
this.roomClient = new RoomServiceClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
RoomService.instance = this;
return this;
}
async createRoom(roomName) {
const roomOptions = {
name: roomName,
metadata: JSON.stringify({
createdBy: APP_NAME,
recordingStatus: "STOPPED"
})
};
return this.roomClient.createRoom(roomOptions);
}
async getRoom(roomName) {
const rooms = await this.roomClient.listRooms([roomName]);
return rooms.length > 0 ? rooms[0] : null;
}
async exists(roomName) {
const room = await this.getRoom(roomName);
return room !== null;
}
async updateRoomMetadata(roomName, recordingStatus) {
const metadata = {
createdBy: APP_NAME,
recordingStatus
};
return this.roomClient.updateRoomMetadata(roomName, JSON.stringify(metadata));
}
async sendDataToRoom(roomName, rawData) {
const data = encoder.encode(JSON.stringify(rawData));
const options = {
topic: "RECORDING_DELETED",
destinationSids: []
};
try {
await this.roomClient.sendData(roomName, data, DataPacket_Kind.RELIABLE, options);
} catch (error) {
console.error("Error sending data to room", error);
}
}
}

View File

@ -10,6 +10,6 @@ S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
AWS_REGION=us-east-1 AWS_REGION=us-east-1
S3_BUCKET=openvidu S3_BUCKET=openvidu-appdata
RECORDINGS_PATH=recordings/ RECORDINGS_PATH=recordings/
RECORDING_PLAYBACK_STRATEGY=S3 RECORDING_PLAYBACK_STRATEGY=S3

View File

@ -11,7 +11,7 @@ export const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://localhost:9000";
export const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin"; export const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin";
export const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin"; export const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin";
export const AWS_REGION = process.env.AWS_REGION || "us-east-1"; export const AWS_REGION = process.env.AWS_REGION || "us-east-1";
export const S3_BUCKET = process.env.S3_BUCKET || "openvidu"; export const S3_BUCKET = process.env.S3_BUCKET || "openvidu-appdata";
export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/"; export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
export const RECORDINGS_METADATA_PATH = ".metadata/"; export const RECORDINGS_METADATA_PATH = ".metadata/";

View File

@ -0,0 +1,11 @@
SERVER_PORT=6080
# LiveKit configuration
LIVEKIT_URL=http://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# Azure Blob Storage configuration
AZURE_ACCOUNT_NAME=yourstorageaccountname
AZURE_ACCOUNT_KEY=youraccountkey
AZURE_CONTAINER_NAME=openvidu-appdata

View File

@ -0,0 +1,129 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

View File

@ -0,0 +1,30 @@
# OpenVidu Recording Basic Node
Simple video-call application with recording capabilities. It includes a backend built with Node.js with Express and a frontend built with plain HTML, CSS and JavaScript.
For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/advanced-features/recording-basic/).
## Prerequisites
- [Node](https://nodejs.org/en/download)
## Run
1. Download repository
```bash
git clone https://github.com/OpenVidu/openvidu-livekit-tutorials.git
cd openvidu-livekit-tutorials/advanced-features/openvidu-recording-basic-node-azure
```
2. Install dependencies
```bash
npm install
```
3. Run the application
```bash
npm start
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "openvidu-recording-basic-node-azure",
"version": "1.0.0",
"description": "Simple video-call application with recording capabilities",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "NODE_TLS_REJECT_UNAUTHORIZED=0 node src/index.js"
},
"dependencies": {
"@azure/storage-blob": "12.27.0",
"cors": "2.8.5",
"dotenv": "16.4.7",
"express": "5.0.1",
"livekit-server-sdk": "^2.9.7"
}
}

View File

@ -0,0 +1,393 @@
// When running OpenVidu locally, leave this variable empty
// For other deployment type, configure it with correct URL depending on your deployment
var LIVEKIT_URL = "";
configureLiveKitUrl();
const LivekitClient = window.LivekitClient;
var room;
function configureLiveKitUrl() {
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
if (!LIVEKIT_URL) {
if (window.location.hostname === "localhost") {
LIVEKIT_URL = "ws://localhost:7880/";
} else {
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
}
}
}
async function joinRoom() {
// Disable 'Join' button
document.getElementById("join-button").disabled = true;
document.getElementById("join-button").innerText = "Joining...";
// Initialize a new Room object
room = new LivekitClient.Room();
// Specify the actions when events take place in the room
// On every new Track received...
room.on(
LivekitClient.RoomEvent.TrackSubscribed,
(track, _publication, participant) => {
addTrack(track, participant.identity);
}
);
// On every new Track destroyed...
room.on(
LivekitClient.RoomEvent.TrackUnsubscribed,
(track, _publication, participant) => {
track.detach();
document.getElementById(track.sid)?.remove();
if (track.kind === "video") {
removeVideoContainer(participant.identity);
}
}
);
// When recording status changes...
room.on(
LivekitClient.RoomEvent.RecordingStatusChanged,
async (isRecording) => {
await updateRecordingInfo(isRecording);
}
);
try {
// Get the room name and participant name from the form
const roomName = document.getElementById("room-name").value;
const userName = document.getElementById("participant-name").value;
// Get a token from your application server with the room name and participant name
const token = await getToken(roomName, userName);
// Connect to the room with the LiveKit URL and the token
await room.connect(LIVEKIT_URL, token);
// Hide the 'Join room' page and show the 'Room' page
document.getElementById("room-title").innerText = roomName;
document.getElementById("join").hidden = true;
document.getElementById("room").hidden = false;
// Publish your camera and microphone
await room.localParticipant.enableCameraAndMicrophone();
const localVideoTrack = this.room.localParticipant.videoTrackPublications
.values()
.next().value.track;
addTrack(localVideoTrack, userName, true);
// Update recording info
await updateRecordingInfo(room.isRecording);
} catch (error) {
console.log("There was an error connecting to the room:", error.message);
await leaveRoom();
}
}
function addTrack(track, participantIdentity, local = false) {
const element = track.attach();
element.id = track.sid;
/* If the track is a video track, we create a container and append the video element to it
with the participant's identity */
if (track.kind === "video") {
const videoContainer = createVideoContainer(participantIdentity, local);
videoContainer.append(element);
appendParticipantData(
videoContainer,
participantIdentity + (local ? " (You)" : "")
);
} else {
document.getElementById("layout-container").append(element);
}
}
async function leaveRoom() {
// Leave the room by calling 'disconnect' method over the Room object
await room.disconnect();
// Remove all HTML elements inside the layout container
removeAllLayoutElements();
// Remove all recordings from the list
removeAllRecordings();
// Reset recording state
document.getElementById("recording-button").disabled = false;
document.getElementById("recording-button").innerText = "Start Recording";
document.getElementById("recording-button").className = "btn btn-primary";
document.getElementById("recording-text").hidden = true;
// Back to 'Join room' page
document.getElementById("join").hidden = false;
document.getElementById("room").hidden = true;
// Enable 'Join' button
document.getElementById("join-button").disabled = false;
document.getElementById("join-button").innerText = "Join!";
}
window.onbeforeunload = () => {
room?.disconnect();
};
document.addEventListener("DOMContentLoaded", async function () {
var currentPage = window.location.pathname;
if (currentPage === "/recordings.html") {
await listRecordings();
} else {
generateFormValues();
}
// Remove recording video when the dialog is closed
document
.getElementById("recording-video-dialog")
.addEventListener("close", () => {
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = "";
});
});
function generateFormValues() {
document.getElementById("room-name").value = "Test Room";
document.getElementById("participant-name").value =
"Participant" + Math.floor(Math.random() * 100);
}
function createVideoContainer(participantIdentity, local = false) {
const videoContainer = document.createElement("div");
videoContainer.id = `camera-${participantIdentity}`;
videoContainer.className = "video-container";
const layoutContainer = document.getElementById("layout-container");
if (local) {
layoutContainer.prepend(videoContainer);
} else {
layoutContainer.append(videoContainer);
}
return videoContainer;
}
function appendParticipantData(videoContainer, participantIdentity) {
const dataElement = document.createElement("div");
dataElement.className = "participant-data";
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
videoContainer.prepend(dataElement);
}
function removeVideoContainer(participantIdentity) {
const videoContainer = document.getElementById(
`camera-${participantIdentity}`
);
videoContainer?.remove();
}
function removeAllLayoutElements() {
const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => {
element.remove();
});
}
/**
* --------------------------------------------
* GETTING A TOKEN FROM YOUR APPLICATION SERVER
* --------------------------------------------
* The method below request the creation of a token to
* your application server. This prevents the need to expose
* your LiveKit API key and secret to the client side.
*
* In this sample code, there is no user control at all. Anybody could
* access your application server endpoints. In a real production
* environment, your application server must identify the user to allow
* access to the endpoints.
*/
async function getToken(roomName, participantName) {
const [error, body] = await httpRequest("POST", "/token", {
roomName,
participantName,
});
if (error) {
throw new Error(`Failed to get token: ${error.message}`);
}
return body.token;
}
async function updateRecordingInfo(isRecording) {
const recordingButton = document.getElementById("recording-button");
const recordingText = document.getElementById("recording-text");
if (isRecording) {
recordingButton.disabled = false;
recordingButton.innerText = "Stop Recording";
recordingButton.className = "btn btn-danger";
recordingText.hidden = false;
} else {
recordingButton.disabled = false;
recordingButton.innerText = "Start Recording";
recordingButton.className = "btn btn-primary";
recordingText.hidden = true;
}
const roomId = await room.getSid();
await listRecordings(roomId);
}
async function manageRecording() {
const recordingButton = document.getElementById("recording-button");
if (recordingButton.innerText === "Start Recording") {
recordingButton.disabled = true;
recordingButton.innerText = "Starting...";
const [error, _] = await startRecording();
if (error && error.status !== 409) {
recordingButton.disabled = false;
recordingButton.innerText = "Start Recording";
}
} else {
recordingButton.disabled = true;
recordingButton.innerText = "Stopping...";
const [error, _] = await stopRecording();
if (error && error.status !== 409) {
recordingButton.disabled = false;
recordingButton.innerText = "Stop Recording";
}
}
}
async function startRecording() {
return httpRequest("POST", "/recordings/start", {
roomName: room.name,
});
}
async function stopRecording() {
return httpRequest("POST", "/recordings/stop", {
roomName: room.name,
});
}
async function deleteRecording(recordingName) {
const [error, _] = await httpRequest(
"DELETE",
`/recordings/${recordingName}`
);
if (!error || error.status === 404) {
const roomId = await room?.getSid();
await listRecordings(roomId);
}
}
async function listRecordings(roomId) {
let url = "/recordings";
if (roomId) {
url += `?roomId=${roomId}`;
}
const [error, body] = await httpRequest("GET", url);
if (!error) {
const recordings = body.recordings;
showRecordingList(recordings);
}
}
async function httpRequest(method, url, body) {
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: method !== "GET" ? JSON.stringify(body) : undefined,
});
const responseBody = await response.json();
if (!response.ok) {
console.error(responseBody.errorMessage);
const error = {
status: response.status,
message: responseBody.errorMessage,
};
return [error, undefined];
}
return [undefined, responseBody];
} catch (error) {
console.error(error.message);
const errorObj = {
status: 0,
message: error.message,
};
return [errorObj, undefined];
}
}
function showRecordingList(recordings) {
const recordingsList = document.getElementById("recording-list");
if (recordings.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>";
} else {
recordingsList.innerHTML = "";
}
recordings.forEach((recording) => {
const recordingName = recording.name;
const recordingContainer = document.createElement("div");
recordingContainer.className = "recording-container";
recordingContainer.id = recordingName;
recordingContainer.innerHTML = `
<i class="fa-solid fa-file-video"></i>
<div class="recording-info">
<p class="recording-name">${recordingName}</p>
</div>
<div class="recording-actions">
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
<i class="fa-solid fa-play"></i>
</button>
<button title="Delete" class="icon-button delete-button" onclick="deleteRecording('${recordingName}')">
<i class="fa-solid fa-trash"></i>
</button>
</div>
`;
recordingsList.append(recordingContainer);
});
}
function displayRecording(recordingName) {
const recordingVideoDialog = document.getElementById(
"recording-video-dialog"
);
recordingVideoDialog.showModal();
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = `/recordings/${recordingName}`;
}
function closeRecording() {
const recordingVideoDialog = document.getElementById(
"recording-video-dialog"
);
recordingVideoDialog.close();
}
function removeAllRecordings() {
const recordingList = document.getElementById("recording-list").children;
Array.from(recordingList).forEach((recording) => {
recording.remove();
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Recording</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
title="Documentation"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
</div>
</header>
<main>
<div id="join">
<div id="join-dialog">
<h2>Join a Video Room</h2>
<form onsubmit="joinRoom(); return false">
<div>
<label for="participant-name">Participant</label>
<input id="participant-name" class="form-control" type="text" required />
</div>
<div>
<label for="room-name">Room</label>
<input id="room-name" class="form-control" type="text" required />
</div>
<button id="join-button" class="btn btn-lg btn-success" type="submit">Join!</button>
</form>
</div>
<a href="/recordings.html" class="btn btn-primary">View all recordings</a>
</div>
<div id="room" hidden>
<div id="recording-text" hidden><span>Room is being recorded</span></div>
<div id="room-header">
<h2 id="room-title"></h2>
<button class="btn btn-primary" id="recording-button" onclick="manageRecording()">
Start Recording
</button>
<button class="btn btn-danger" id="leave-room-button" onclick="leaveRoom()">Leave Room</button>
</div>
<div id="layout-container"></div>
<div id="recordings">
<div id="recording-header">
<h3>Session Recordings</h3>
<a href="/recordings.html" target="_blank" class="btn btn-sm btn-primary"
>View all recordings</a
>
</div>
<div id="recording-list"></div>
<dialog id="recording-video-dialog">
<video id="recording-video" autoplay controls></video>
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
Close
</button>
</dialog>
</div>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="https://openvidu.io/" target="_blank">
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Recording</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
title="Documentation"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
</div>
</header>
<main>
<div id="recordings-all">
<h2>Recordings</h2>
<div id="recording-list"></div>
<dialog id="recording-video-dialog">
<video id="recording-video" autoplay controls></video>
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
Close
</button>
</dialog>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="https://openvidu.io/" target="_blank">
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

View File

@ -0,0 +1,479 @@
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
padding-top: 50px;
display: flex;
flex-direction: column;
height: 100%;
}
header {
height: 50px;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 30px;
background-color: #4d4d4d;
}
header h1 {
margin: 0;
font-size: 1.5em;
font-weight: bold;
}
header a {
color: #ccc;
text-decoration: none;
}
header a:hover {
color: #a9a9a9;
}
header i {
padding: 5px 5px;
font-size: 2em;
}
main {
flex: 1;
padding: 20px;
}
#join {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
height: 100%;
}
#join-dialog {
width: 70%;
max-width: 900px;
padding: 60px;
border-radius: 6px;
background-color: #f0f0f0;
}
#join-dialog h2 {
color: #4d4d4d;
font-size: 60px;
font-weight: bold;
text-align: center;
}
#join-dialog form {
text-align: left;
}
#join-dialog label {
display: block;
margin-bottom: 10px;
color: #0088aa;
font-weight: bold;
font-size: 20px;
}
.form-control {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
color: #0088aa;
font-weight: bold;
}
.form-control:focus {
color: #0088aa;
border-color: #0088aa;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
}
#join-dialog button {
display: block;
margin: 20px auto 0;
}
.btn {
font-weight: bold;
}
.btn-success {
background-color: #06d362;
border-color: #06d362;
}
.btn-success:hover {
background-color: #1abd61;
border-color: #1abd61;
}
#room {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#recording-text {
width: 100%;
max-width: 1000px;
}
#recording-text span {
background-color: #ffeb3b;
color: #333;
border-radius: 5px;
padding: 0 5px;
font-weight: bold;
font-style: italic;
float: right;
margin-bottom: 10px;
}
#room-header {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 1000px;
padding: 0 20px;
margin-bottom: 20px;
}
#room-title {
font-size: 2em;
font-weight: bold;
margin: 0;
}
#layout-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
height: 100%;
}
.video-container {
position: relative;
background: #3b3b3b;
aspect-ratio: 16/9;
border-radius: 6px;
overflow: hidden;
}
.video-container video {
width: 100%;
height: 100%;
}
.video-container .participant-data {
position: absolute;
top: 0;
left: 0;
}
.participant-data p {
background: #f8f8f8;
margin: 0;
padding: 0 5px;
color: #777777;
font-weight: bold;
border-bottom-right-radius: 4px;
}
#recordings {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
}
#recording-header {
margin: 15px 0;
width: 100%;
display: grid;
grid-template-columns: 1fr 3fr 1fr;
align-items: center;
gap: 10px;
}
#recording-header h3 {
margin: 0;
text-align: center;
font-size: 30px;
font-weight: bold;
color: #444;
grid-column-start: 2;
}
#recording-header a {
margin-left: auto;
}
#recording-list {
width: 600px;
max-width: 100%;
}
#recording-list span {
font-size: 16px;
font-weight: bold;
font-style: italic;
color: #333;
display: block;
margin: 0;
text-align: center;
}
.recording-container {
display: flex;
align-items: center;
border: 1px solid #dcdcdc;
border-radius: 5px;
padding: 10px;
background-color: #f4f4f4;
margin: 10px;
}
.fa-file-video {
font-size: 30px;
color: #333;
margin-right: 10px;
}
.recording-info {
flex-grow: 1;
font-size: 14px;
color: #333;
}
.recording-info p {
margin: 0;
}
.recording-name {
font-weight: bold;
}
.recording-size,
.recording-date {
color: #777;
}
.recording-date {
font-style: italic;
}
.recording-actions {
display: flex;
align-items: center;
}
.icon-button {
background-color: transparent;
border: none;
color: #333;
margin-left: 10px;
padding: 4px 12px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 20px;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.icon-button:hover {
background-color: #e0e0e0;
}
.delete-button {
color: #d9534f;
}
#recording-video-dialog {
width: 500px;
max-width: 90%;
border: none;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
padding: 20px;
background-color: #fff;
}
#recording-video {
width: 100%;
border-radius: 5px;
}
#close-recording-video-dialog {
margin-top: 10px;
float: right;
}
footer {
height: 60px;
width: 100%;
padding: 10px 30px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #4d4d4d;
}
footer a {
color: #ffffff;
text-decoration: none;
}
footer .text {
color: #ccc;
margin: 0;
}
footer .text span {
color: white;
font-weight: bold;
}
#openvidu-logo {
height: 35px;
-webkit-transition: all 0.1s ease-in-out;
-moz-transition: all 0.1s ease-in-out;
-o-transition: all 0.1s ease-in-out;
transition: all 0.1s ease-in-out;
}
#openvidu-logo:hover {
-webkit-filter: grayscale(0.5);
filter: grayscale(0.5);
}
/* Media Queries */
@media screen and (max-width: 768px) {
header {
padding: 10px 15px;
}
#join-dialog {
width: 90%;
padding: 30px;
}
#join-dialog h2 {
font-size: 50px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
footer {
padding: 10px 15px;
}
}
@media screen and (max-width: 480px) {
header {
padding: 10px;
}
#join-dialog {
width: 100%;
padding: 20px;
}
#join-dialog h2 {
font-size: 40px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.video-container {
aspect-ratio: 9/16;
}
footer {
padding: 10px;
}
}
/* Recordings page styles */
#actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
width: 100%;
max-width: 1000px;
margin-bottom: 20px;
}
#refresh-button {
margin: 0;
padding: 8px 13px;
}
form input {
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
form button {
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-left: none;
background-color: #007bff;
color: #fff;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
.search-form button:hover {
background-color: #0056b3;
}
#recordings-all {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#recordings-all h2 {
width: 100%;
max-width: 1000px;
margin-bottom: 20px;
font-size: 40px;
font-weight: bold;
text-align: center;
color: #444;
}

View File

@ -0,0 +1,91 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
// Azure configuration
const AZURE_ACCOUNT_NAME = process.env.AZURE_ACCOUNT_NAME || "devstoreaccount";
const AZURE_ACCOUNT_KEY =
process.env.AZURE_ACCOUNT_KEY || "nokey";
const AZURE_CONTAINER_NAME =
process.env.AZURE_CONTAINER_NAME || "openvidu-appdata";
const AZURE_ENDPOINT =
process.env.AZURE_ENDPOINT ||
`https://${AZURE_ACCOUNT_NAME}.blob.core.windows.net`;
export class AzureBlobService {
static instance;
constructor() {
if (AzureBlobService.instance) {
return AzureBlobService.instance;
}
const sharedKeyCredential = new StorageSharedKeyCredential(
AZURE_ACCOUNT_NAME,
AZURE_ACCOUNT_KEY
);
this.blobServiceClient = new BlobServiceClient(
AZURE_ENDPOINT,
sharedKeyCredential
);
this.containerClient = this.blobServiceClient.getContainerClient(
AZURE_CONTAINER_NAME
);
AzureBlobService.instance = this;
return this;
}
async exists(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.exists();
}
async getObjectSize(key) {
const props = await this.headObject(key);
return props.contentLength;
}
async headObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
const props = await blobClient.getProperties();
return props;
}
async getObject(key, range) {
const blobClient = this.containerClient.getBlobClient(key);
const downloadOptions = range
? {
rangeGetContentMD5: false,
range: {
offset: range.start,
count: range.end - range.start + 1,
},
}
: {};
const response = await blobClient.download(
downloadOptions.range?.offset ?? 0,
downloadOptions.range?.count
);
return response.readableStreamBody;
}
async listObjects() {
const result = [];
for await (const blob of this.containerClient.listBlobsFlat()) {
result.push(blob);
}
return result;
}
async deleteObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.deleteIfExists();
}
}

View File

@ -0,0 +1,255 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import {
AccessToken,
EgressClient,
EncodedFileOutput,
EncodedFileType,
WebhookReceiver,
} from "livekit-server-sdk";
import { AzureBlobService } from "./azure.blobstorage.service.js";
// Configuration
const SERVER_PORT = process.env.SERVER_PORT || 6080;
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.raw({ type: "application/webhook+json" }));
// Set the static files location
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, "../public")));
const egressClient = new EgressClient(
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
const azureBlobService = new AzureBlobService();
const webhookReceiver = new WebhookReceiver(
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
// Generate access tokens for participants to join a room
app.post("/token", async (req, res) => {
const roomName = req.body.roomName;
const participantName = req.body.participantName;
if (!roomName || !participantName) {
return res
.status(400)
.json({ errorMessage: "roomName and participantName are required" });
}
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName,
});
at.addGrant({ roomJoin: true, room: roomName, roomRecord: true });
const token = await at.toJwt();
return res.json({ token });
});
// Receive webhooks from LiveKit Server
app.post("/livekit/webhook", async (req, res) => {
try {
const event = await webhookReceiver.receive(
req.body,
req.get("Authorization")
);
console.log(event);
} catch (error) {
console.error("Error validating webhook event.", error);
}
return res.status(200).send();
});
// Start a recording
app.post("/recordings/start", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is already an active recording for this room
if (activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording already started for this room" });
}
// Use the EncodedFileOutput to save the recording to an MP4 file
// The room name, time and room ID in the file path help to organize the recordings
const fileOutput = new EncodedFileOutput({
fileType: EncodedFileType.MP4,
filepath: `RoomComposite-{room_name}-{time}-{room_id}`,
disableManifest: true,
});
try {
// Start a RoomCompositeEgress to record all participants in the room
const egressInfo = await egressClient.startRoomCompositeEgress(roomName, {
file: fileOutput,
});
const recording = {
name: egressInfo.fileResults[0].filename.split("/").pop(),
startedAt: Number(egressInfo.startedAt) / 1_000_000,
};
res.json({ message: "Recording started", recording });
} catch (error) {
console.error("Error starting recording.", error);
res.status(500).json({ errorMessage: "Error starting recording" });
}
});
// Stop a recording
app.post("/recordings/stop", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is an active recording for this room
if (!activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording not started for this room" });
}
try {
// Stop the egress to finish the recording
const egressInfo = await egressClient.stopEgress(activeRecording);
const file = egressInfo.fileResults[0];
const recording = {
name: file.filename.split("/").pop(),
};
return res.json({ message: "Recording stopped", recording });
} catch (error) {
console.error("Error stopping recording.", error);
return res.status(500).json({ errorMessage: "Error stopping recording" });
}
});
// List recordings
app.get("/recordings", async (req, res) => {
const roomId = req.query.roomId?.toString();
try {
const azureResponse = await azureBlobService.listObjects();
let recordings = [];
if (azureResponse.length > 0) {
recordings = azureResponse.map((obj) => ({
name: obj.name,
}));
}
// Filter recordings by room ID
recordings = recordings.filter((recording) =>
roomId ? recording.name.includes(roomId) : true
);
return res.json({ recordings });
} catch (error) {
console.error("Error listing recordings.", error);
return res.status(500).json({ errorMessage: "Error listing recordings" });
}
});
// Play a recording
app.get("/recordings/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const { range } = req.headers;
const exists = await azureBlobService.exists(recordingName);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Get the recording file from Azure Blob Storage
const { stream, size, start, end } = await getRecordingStream(
recordingName,
range
);
// Set response headers
res.status(206);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "video/mp4");
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
res.setHeader("Content-Length", end - start + 1);
// Pipe the recording file to the response
stream.pipe(res).on("finish", () => res.end());
} catch (error) {
console.error("Error getting recording.", error);
return res.status(500).json({ errorMessage: "Error getting recording" });
}
});
// Delete a recording
app.delete("/recordings/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const exists = await azureBlobService.exists(recordingName);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Delete the recording file from Azure Blob Storage
await Promise.all([azureBlobService.deleteObject(recordingName)]);
res.json({ message: "Recording deleted" });
} catch (error) {
console.error("Error deleting recording.", error);
res.status(500).json({ errorMessage: "Error deleting recording" });
}
});
// Start the server
app.listen(SERVER_PORT, () => {
console.log("Server started on port:", SERVER_PORT);
});
// Helper functions
const getActiveRecordingByRoom = async (roomName) => {
try {
// List all active egresses for the room
const egresses = await egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
return null;
}
};
const getRecordingStream = async (recordingName, range) => {
const size = await azureBlobService.getObjectSize(recordingName);
// Get the requested range
const parts = range?.replace(/bytes=/, "").split("-");
const start = range ? parseInt(parts[0], 10) : 0;
const endRange = parts[1]
? parseInt(parts[1], 10)
: start + RECORDING_FILE_PORTION_SIZE;
const end = Math.min(endRange, size - 1);
const stream = await azureBlobService.getObject(recordingName, { start, end });
return { stream, size, start, end };
};

View File

@ -10,5 +10,5 @@ S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
AWS_REGION=us-east-1 AWS_REGION=us-east-1
S3_BUCKET=openvidu S3_BUCKET=openvidu-appdata
RECORDINGS_PATH=recordings/ RECORDINGS_PATH=recordings/

View File

@ -217,7 +217,7 @@ app.delete("/recordings/:recordingName", async (req, res) => {
} }
try { try {
// Delete the recording file file from S3 // Delete the recording file from S3
await Promise.all([s3Service.deleteObject(key)]); await Promise.all([s3Service.deleteObject(key)]);
res.json({ message: "Recording deleted" }); res.json({ message: "Recording deleted" });
} catch (error) { } catch (error) {

View File

@ -11,7 +11,7 @@ const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://localhost:9000";
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin"; const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin";
const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin"; const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin";
const AWS_REGION = process.env.AWS_REGION || "us-east-1"; const AWS_REGION = process.env.AWS_REGION || "us-east-1";
const S3_BUCKET = process.env.S3_BUCKET || "openvidu"; const S3_BUCKET = process.env.S3_BUCKET || "openvidu-appdata";
export class S3Service { export class S3Service {
static instance; static instance;

View File

@ -0,0 +1,5 @@
# OpenVidu Captions
This is the basic JavaScript tutorial extended to show live captions. It uses OpenVidu AI Services to generate captions from the audio Tracks of Participants in the Rooms.
Visit [Live Captions tutorial](https://openvidu.io/latest/docs/tutorials/ai-services/openvidu-live-captions/)

View File

@ -0,0 +1,221 @@
// When running OpenVidu locally, leave these variables empty
// For other deployment type, configure them with correct URLs depending on your deployment
var APPLICATION_SERVER_URL = "";
var LIVEKIT_URL = "";
configureUrls();
const LivekitClient = window.LivekitClient;
var room;
function configureUrls() {
// If APPLICATION_SERVER_URL is not configured, use default value from OpenVidu Local deployment
if (!APPLICATION_SERVER_URL) {
if (window.location.hostname === "localhost") {
APPLICATION_SERVER_URL = "http://localhost:6080/";
} else {
APPLICATION_SERVER_URL = "https://" + window.location.hostname + ":6443/";
}
}
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
if (!LIVEKIT_URL) {
if (window.location.hostname === "localhost") {
LIVEKIT_URL = "ws://localhost:7880/";
} else {
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
}
}
}
async function joinRoom() {
// Disable 'Join' button
document.getElementById("join-button").disabled = true;
document.getElementById("join-button").innerText = "Joining...";
// Initialize a new Room object
room = new LivekitClient.Room();
// Specify the actions when events take place in the room
// On every new Track received...
room.on(
LivekitClient.RoomEvent.TrackSubscribed,
(track, _publication, participant) => {
addTrack(track, participant.identity);
}
);
// On every new Track destroyed...
room.on(
LivekitClient.RoomEvent.TrackUnsubscribed,
(track, _publication, participant) => {
track.detach();
document.getElementById(track.sid)?.remove();
if (track.kind === "video") {
removeVideoContainer(participant.identity);
}
}
);
room.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
const message = await reader.readAll();
const isFinal = reader.info.attributes["lk.transcription_final"] === "true";
const trackId = reader.info.attributes["lk.transcribed_track_id"];
if (isFinal) {
const speaker = participantInfo.identity == room.localParticipant.identity
? "You" : participantInfo.identity;
const timestamp = new Date().toLocaleTimeString();
const captionsTextarea = document.getElementById("captions");
captionsTextarea.value += `[${timestamp}] ${speaker}: ${message}\n`;
captionsTextarea.scrollTop = captionsTextarea.scrollHeight;
}
}
);
try {
// Get the room name and participant name from the form
const roomName = document.getElementById("room-name").value;
const userName = document.getElementById("participant-name").value;
// Get a token from your application server with the room name and participant name
const token = await getToken(roomName, userName);
// Connect to the room with the LiveKit URL and the token
await room.connect(LIVEKIT_URL, token);
// Hide the 'Join room' page and show the 'Room' page
document.getElementById("room-title").innerText = roomName;
document.getElementById("join").hidden = true;
document.getElementById("room").hidden = false;
// Publish your camera and microphone
await room.localParticipant.enableCameraAndMicrophone();
const localVideoTrack = this.room.localParticipant.videoTrackPublications
.values()
.next().value.track;
addTrack(localVideoTrack, userName, true);
} catch (error) {
console.log("There was an error connecting to the room:", error.message);
await leaveRoom();
}
}
function addTrack(track, participantIdentity, local = false) {
const element = track.attach();
element.id = track.sid;
/* If the track is a video track, we create a container and append the video element to it
with the participant's identity */
if (track.kind === "video") {
const videoContainer = createVideoContainer(participantIdentity, local);
videoContainer.append(element);
appendParticipantData(
videoContainer,
participantIdentity + (local ? " (You)" : "")
);
} else {
document.getElementById("layout-container").append(element);
}
}
async function leaveRoom() {
// Leave the room by calling 'disconnect' method over the Room object
await room.disconnect();
// Remove all HTML elements inside the layout container
removeAllLayoutElements();
// Clear the captions textarea
document.getElementById("captions").value = "";
// Back to 'Join room' page
document.getElementById("join").hidden = false;
document.getElementById("room").hidden = true;
// Enable 'Join' button
document.getElementById("join-button").disabled = false;
document.getElementById("join-button").innerText = "Join!";
}
window.onbeforeunload = () => {
room?.disconnect();
};
window.onload = generateFormValues;
function generateFormValues() {
document.getElementById("room-name").value = "Test Room";
document.getElementById("participant-name").value =
"Participant" + Math.floor(Math.random() * 100);
}
function createVideoContainer(participantIdentity, local = false) {
const videoContainer = document.createElement("div");
videoContainer.id = `camera-${participantIdentity}`;
videoContainer.className = "video-container";
const layoutContainer = document.getElementById("layout-container");
if (local) {
layoutContainer.prepend(videoContainer);
} else {
layoutContainer.append(videoContainer);
}
return videoContainer;
}
function appendParticipantData(videoContainer, participantIdentity) {
const dataElement = document.createElement("div");
dataElement.className = "participant-data";
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
videoContainer.prepend(dataElement);
}
function removeVideoContainer(participantIdentity) {
const videoContainer = document.getElementById(
`camera-${participantIdentity}`
);
videoContainer?.remove();
}
function removeAllLayoutElements() {
const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => {
element.remove();
});
}
/**
* --------------------------------------------
* GETTING A TOKEN FROM YOUR APPLICATION SERVER
* --------------------------------------------
* The method below request the creation of a token to
* your application server. This prevents the need to expose
* your LiveKit API key and secret to the client side.
*
* In this sample code, there is no user control at all. Anybody could
* access your application server endpoints. In a real production
* environment, your application server must identify the user to allow
* access to the endpoints.
*/
async function getToken(roomName, participantName) {
const response = await fetch(APPLICATION_SERVER_URL + "token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roomName,
participantName,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get token: ${error.errorMessage}`);
}
const token = await response.json();
return token.token;
}

View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Live Captions</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="resources/images/favicon.ico" type="image/x-icon" />
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.14.0/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Live Captions</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/application-client/openvidu-js"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/application-client/javascript/"
title="Documentation"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
</div>
</header>
<main>
<div id="join">
<div id="join-dialog">
<h2>Join a Video Room</h2>
<form onsubmit="joinRoom(); return false">
<div>
<label for="participant-name">Participant</label>
<input id="participant-name" class="form-control" type="text" required />
</div>
<div>
<label for="room-name">Room</label>
<input id="room-name" class="form-control" type="text" required />
</div>
<button id="join-button" class="btn btn-lg btn-success" type="submit">Join!</button>
</form>
</div>
</div>
<div id="room" hidden>
<div id="room-header">
<h2 id="room-title"></h2>
<button class="btn btn-danger" id="leave-room-button" onclick="leaveRoom()">Leave Room</button>
</div>
<div id="layout-container"></div>
<div id="captions-container">
<textarea id="captions" class="form-control" rows="3"></textarea>
</div>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="http://www.openvidu.io/" target="_blank">
<img id="openvidu-logo" src="resources/images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,283 @@
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
padding-top: 50px;
display: flex;
flex-direction: column;
height: 100%;
}
header {
height: 50px;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 30px;
background-color: #4d4d4d;
}
header h1 {
margin: 0;
font-size: 1.5em;
font-weight: bold;
}
header a {
color: #ccc;
text-decoration: none;
}
header a:hover {
color: #a9a9a9;
}
header i {
padding: 5px 5px;
font-size: 2em;
}
main {
flex: 1;
padding: 20px;
}
#join {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
#join-dialog {
width: 70%;
max-width: 900px;
padding: 60px;
border-radius: 6px;
background-color: #f0f0f0;
}
#join-dialog h2 {
color: #4d4d4d;
font-size: 60px;
font-weight: bold;
text-align: center;
}
#join-dialog form {
text-align: left;
}
#join-dialog label {
display: block;
margin-bottom: 10px;
color: #0088aa;
font-weight: bold;
font-size: 20px;
}
.form-control {
width: 100%;
padding: 8px;
margin-bottom: 10px;
box-sizing: border-box;
color: #0088aa;
font-weight: bold;
}
.form-control:focus {
color: #0088aa;
border-color: #0088aa;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
}
#join-dialog button {
display: block;
margin: 20px auto 0;
}
.btn {
font-weight: bold;
}
.btn-success {
background-color: #06d362;
border-color: #06d362;
}
.btn-success:hover {
background-color: #1abd61;
border-color: #1abd61;
}
#room {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#room-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 1000px;
padding: 0 20px;
margin-bottom: 20px;
}
#room-title {
font-size: 2em;
font-weight: bold;
margin: 0;
}
#layout-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
height: 100%;
}
.video-container {
position: relative;
background: #3b3b3b;
aspect-ratio: 16/9;
border-radius: 6px;
overflow: hidden;
}
.video-container video {
width: 100%;
height: 100%;
}
.video-container .participant-data {
position: absolute;
top: 0;
left: 0;
}
.participant-data p {
background: #f8f8f8;
margin: 0;
padding: 0 5px;
color: #777777;
font-weight: bold;
border-bottom-right-radius: 4px;
}
footer {
height: 60px;
width: 100%;
padding: 10px 30px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #4d4d4d;
}
footer a {
color: #ffffff;
text-decoration: none;
}
footer .text {
color: #ccc;
margin: 0;
}
footer .text span {
color: white;
font-weight: bold;
}
#openvidu-logo {
height: 35px;
-webkit-transition: all 0.1s ease-in-out;
-moz-transition: all 0.1s ease-in-out;
-o-transition: all 0.1s ease-in-out;
transition: all 0.1s ease-in-out;
}
#openvidu-logo:hover {
-webkit-filter: grayscale(0.5);
filter: grayscale(0.5);
}
/* Media Queries */
@media screen and (max-width: 768px) {
header {
padding: 10px 15px;
}
#join-dialog {
width: 90%;
padding: 30px;
}
#join-dialog h2 {
font-size: 50px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
footer {
padding: 10px 15px;
}
}
@media screen and (max-width: 480px) {
header {
padding: 10px;
}
#join-dialog {
width: 100%;
padding: 20px;
}
#join-dialog h2 {
font-size: 40px;
}
#layout-container {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.video-container {
aspect-ratio: 9/16;
}
footer {
padding: 10px;
}
}
#captions-container {
width: 100%;
padding: 25px;
}
textarea {
width: 100%;
min-height: 100px;
padding: 10px;
}