Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc4f43e887 | ||
|
|
12b2c3720a | ||
|
|
af880945dc | ||
|
|
d385068049 | ||
|
|
50a7af992a | ||
|
|
f0358d0680 | ||
|
|
4e90828d80 | ||
|
|
75076678a5 | ||
|
|
b1aeb2f2f2 | ||
|
|
1c57c6e85d | ||
|
|
d3180c1f9f | ||
|
|
9cdb39a53b | ||
|
|
d5dd493a37 | ||
|
|
8841cd2aed | ||
|
|
665681174c | ||
|
|
966ba75a8a | ||
|
|
3228a50708 | ||
|
|
e10eeb0fdc | ||
|
|
5343660dfa | ||
|
|
48e1fb79f7 |
@ -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
|
||||||
129
advanced-features/openvidu-recording-advanced-node-azure/.gitignore
vendored
Normal file
129
advanced-features/openvidu-recording-advanced-node-azure/.gitignore
vendored
Normal 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.*
|
||||||
@ -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
|
||||||
|
```
|
||||||
1290
advanced-features/openvidu-recording-advanced-node-azure/package-lock.json
generated
Normal file
1290
advanced-features/openvidu-recording-advanced-node-azure/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
@ -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>
|
||||||
@ -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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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/";
|
||||||
|
|||||||
11
advanced-features/openvidu-recording-basic-node-azure/.env
Normal file
11
advanced-features/openvidu-recording-basic-node-azure/.env
Normal 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
|
||||||
129
advanced-features/openvidu-recording-basic-node-azure/.gitignore
vendored
Normal file
129
advanced-features/openvidu-recording-basic-node-azure/.gitignore
vendored
Normal 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.*
|
||||||
@ -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
|
||||||
|
```
|
||||||
1340
advanced-features/openvidu-recording-basic-node-azure/package-lock.json
generated
Normal file
1340
advanced-features/openvidu-recording-basic-node-azure/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
@ -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>
|
||||||
@ -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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
};
|
||||||
@ -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/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -8,10 +8,10 @@
|
|||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.654.0",
|
"@aws-sdk/client-s3": "3.744.0",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.7",
|
||||||
"express": "4.21.0",
|
"express": "5.0.1",
|
||||||
"livekit-server-sdk": "^2.7.2"
|
"livekit-server-sdk": "^2.9.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,171 +7,190 @@ const LivekitClient = window.LivekitClient;
|
|||||||
var room;
|
var room;
|
||||||
|
|
||||||
function configureLiveKitUrl() {
|
function configureLiveKitUrl() {
|
||||||
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
|
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
|
||||||
if (!LIVEKIT_URL) {
|
if (!LIVEKIT_URL) {
|
||||||
if (window.location.hostname === "localhost") {
|
if (window.location.hostname === "localhost") {
|
||||||
LIVEKIT_URL = "ws://localhost:7880/";
|
LIVEKIT_URL = "ws://localhost:7880/";
|
||||||
} else {
|
} else {
|
||||||
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
|
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoom() {
|
async function joinRoom() {
|
||||||
// Disable 'Join' button
|
// Disable 'Join' button
|
||||||
document.getElementById("join-button").disabled = true;
|
document.getElementById("join-button").disabled = true;
|
||||||
document.getElementById("join-button").innerText = "Joining...";
|
document.getElementById("join-button").innerText = "Joining...";
|
||||||
|
|
||||||
// Initialize a new Room object
|
// Initialize a new Room object
|
||||||
room = new LivekitClient.Room();
|
room = new LivekitClient.Room();
|
||||||
|
|
||||||
// Specify the actions when events take place in the room
|
// Specify the actions when events take place in the room
|
||||||
// On every new Track received...
|
// On every new Track received...
|
||||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _publication, participant) => {
|
room.on(
|
||||||
addTrack(track, participant.identity);
|
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();
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) {
|
function addTrack(track, participantIdentity, local = false) {
|
||||||
const element = track.attach();
|
const element = track.attach();
|
||||||
element.id = track.sid;
|
element.id = track.sid;
|
||||||
|
|
||||||
/* If the track is a video track, we create a container and append the video element to it
|
/* If the track is a video track, we create a container and append the video element to it
|
||||||
with the participant's identity */
|
with the participant's identity */
|
||||||
if (track.kind === "video") {
|
if (track.kind === "video") {
|
||||||
const videoContainer = createVideoContainer(participantIdentity, local);
|
const videoContainer = createVideoContainer(participantIdentity, local);
|
||||||
videoContainer.append(element);
|
videoContainer.append(element);
|
||||||
appendParticipantData(videoContainer, participantIdentity + (local ? " (You)" : ""));
|
appendParticipantData(
|
||||||
} else {
|
videoContainer,
|
||||||
document.getElementById("layout-container").append(element);
|
participantIdentity + (local ? " (You)" : "")
|
||||||
}
|
);
|
||||||
|
} else {
|
||||||
|
document.getElementById("layout-container").append(element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveRoom() {
|
async function leaveRoom() {
|
||||||
// Leave the room by calling 'disconnect' method over the Room object
|
// Leave the room by calling 'disconnect' method over the Room object
|
||||||
await room.disconnect();
|
await room.disconnect();
|
||||||
|
|
||||||
// Remove all HTML elements inside the layout container
|
// Remove all HTML elements inside the layout container
|
||||||
removeAllLayoutElements();
|
removeAllLayoutElements();
|
||||||
|
|
||||||
// Remove all recordings from the list
|
// Remove all recordings from the list
|
||||||
removeAllRecordings();
|
removeAllRecordings();
|
||||||
|
|
||||||
// Reset recording state
|
// Reset recording state
|
||||||
document.getElementById("recording-button").disabled = false;
|
document.getElementById("recording-button").disabled = false;
|
||||||
document.getElementById("recording-button").innerText = "Start Recording";
|
document.getElementById("recording-button").innerText = "Start Recording";
|
||||||
document.getElementById("recording-button").className = "btn btn-primary";
|
document.getElementById("recording-button").className = "btn btn-primary";
|
||||||
document.getElementById("recording-text").hidden = true;
|
document.getElementById("recording-text").hidden = true;
|
||||||
|
|
||||||
// Back to 'Join room' page
|
// Back to 'Join room' page
|
||||||
document.getElementById("join").hidden = false;
|
document.getElementById("join").hidden = false;
|
||||||
document.getElementById("room").hidden = true;
|
document.getElementById("room").hidden = true;
|
||||||
|
|
||||||
// Enable 'Join' button
|
// Enable 'Join' button
|
||||||
document.getElementById("join-button").disabled = false;
|
document.getElementById("join-button").disabled = false;
|
||||||
document.getElementById("join-button").innerText = "Join!";
|
document.getElementById("join-button").innerText = "Join!";
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onbeforeunload = () => {
|
window.onbeforeunload = () => {
|
||||||
room?.disconnect();
|
room?.disconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async function () {
|
document.addEventListener("DOMContentLoaded", async function () {
|
||||||
var currentPage = window.location.pathname;
|
var currentPage = window.location.pathname;
|
||||||
|
|
||||||
if (currentPage === "/recordings.html") {
|
if (currentPage === "/recordings.html") {
|
||||||
await listRecordings();
|
await listRecordings();
|
||||||
} else {
|
} else {
|
||||||
generateFormValues();
|
generateFormValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove recording video when the dialog is closed
|
// Remove recording video when the dialog is closed
|
||||||
document.getElementById("recording-video-dialog").addEventListener("close", () => {
|
document
|
||||||
const recordingVideo = document.getElementById("recording-video");
|
.getElementById("recording-video-dialog")
|
||||||
recordingVideo.src = "";
|
.addEventListener("close", () => {
|
||||||
|
const recordingVideo = document.getElementById("recording-video");
|
||||||
|
recordingVideo.src = "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateFormValues() {
|
function generateFormValues() {
|
||||||
document.getElementById("room-name").value = "Test Room";
|
document.getElementById("room-name").value = "Test Room";
|
||||||
document.getElementById("participant-name").value = "Participant" + Math.floor(Math.random() * 100);
|
document.getElementById("participant-name").value =
|
||||||
|
"Participant" + Math.floor(Math.random() * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createVideoContainer(participantIdentity, local = false) {
|
function createVideoContainer(participantIdentity, local = false) {
|
||||||
const videoContainer = document.createElement("div");
|
const videoContainer = document.createElement("div");
|
||||||
videoContainer.id = `camera-${participantIdentity}`;
|
videoContainer.id = `camera-${participantIdentity}`;
|
||||||
videoContainer.className = "video-container";
|
videoContainer.className = "video-container";
|
||||||
const layoutContainer = document.getElementById("layout-container");
|
const layoutContainer = document.getElementById("layout-container");
|
||||||
|
|
||||||
if (local) {
|
if (local) {
|
||||||
layoutContainer.prepend(videoContainer);
|
layoutContainer.prepend(videoContainer);
|
||||||
} else {
|
} else {
|
||||||
layoutContainer.append(videoContainer);
|
layoutContainer.append(videoContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return videoContainer;
|
return videoContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendParticipantData(videoContainer, participantIdentity) {
|
function appendParticipantData(videoContainer, participantIdentity) {
|
||||||
const dataElement = document.createElement("div");
|
const dataElement = document.createElement("div");
|
||||||
dataElement.className = "participant-data";
|
dataElement.className = "participant-data";
|
||||||
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
|
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
|
||||||
videoContainer.prepend(dataElement);
|
videoContainer.prepend(dataElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeVideoContainer(participantIdentity) {
|
function removeVideoContainer(participantIdentity) {
|
||||||
const videoContainer = document.getElementById(`camera-${participantIdentity}`);
|
const videoContainer = document.getElementById(
|
||||||
videoContainer?.remove();
|
`camera-${participantIdentity}`
|
||||||
|
);
|
||||||
|
videoContainer?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAllLayoutElements() {
|
function removeAllLayoutElements() {
|
||||||
const layoutElements = document.getElementById("layout-container").children;
|
const layoutElements = document.getElementById("layout-container").children;
|
||||||
Array.from(layoutElements).forEach((element) => {
|
Array.from(layoutElements).forEach((element) => {
|
||||||
element.remove();
|
element.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -188,156 +207,153 @@ function removeAllLayoutElements() {
|
|||||||
* access to the endpoints.
|
* access to the endpoints.
|
||||||
*/
|
*/
|
||||||
async function getToken(roomName, participantName) {
|
async function getToken(roomName, participantName) {
|
||||||
const [error, body] = await httpRequest("POST", "/token", {
|
const [error, body] = await httpRequest("POST", "/token", {
|
||||||
roomName,
|
roomName,
|
||||||
participantName
|
participantName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(`Failed to get token: ${error.message}`);
|
throw new Error(`Failed to get token: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return body.token;
|
return body.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRecordingInfo(isRecording) {
|
async function updateRecordingInfo(isRecording) {
|
||||||
const recordingButton = document.getElementById("recording-button");
|
const recordingButton = document.getElementById("recording-button");
|
||||||
const recordingText = document.getElementById("recording-text");
|
const recordingText = document.getElementById("recording-text");
|
||||||
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
recordingButton.disabled = false;
|
recordingButton.disabled = false;
|
||||||
recordingButton.innerText = "Stop Recording";
|
recordingButton.innerText = "Stop Recording";
|
||||||
recordingButton.className = "btn btn-danger";
|
recordingButton.className = "btn btn-danger";
|
||||||
recordingText.hidden = false;
|
recordingText.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
recordingButton.disabled = false;
|
recordingButton.disabled = false;
|
||||||
recordingButton.innerText = "Start Recording";
|
recordingButton.innerText = "Start Recording";
|
||||||
recordingButton.className = "btn btn-primary";
|
recordingButton.className = "btn btn-primary";
|
||||||
recordingText.hidden = true;
|
recordingText.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = await room.getSid();
|
const roomId = await room.getSid();
|
||||||
await listRecordings(room.name, roomId);
|
await listRecordings(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function manageRecording() {
|
async function manageRecording() {
|
||||||
const recordingButton = document.getElementById("recording-button");
|
const recordingButton = document.getElementById("recording-button");
|
||||||
|
|
||||||
if (recordingButton.innerText === "Start Recording") {
|
if (recordingButton.innerText === "Start Recording") {
|
||||||
recordingButton.disabled = true;
|
recordingButton.disabled = true;
|
||||||
recordingButton.innerText = "Starting...";
|
recordingButton.innerText = "Starting...";
|
||||||
|
|
||||||
const [error, _] = await startRecording();
|
const [error, _] = await startRecording();
|
||||||
|
|
||||||
if (error && error.status !== 409) {
|
if (error && error.status !== 409) {
|
||||||
recordingButton.disabled = false;
|
recordingButton.disabled = false;
|
||||||
recordingButton.innerText = "Start Recording";
|
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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() {
|
async function startRecording() {
|
||||||
return httpRequest("POST", "/recordings/start", {
|
return httpRequest("POST", "/recordings/start", {
|
||||||
roomName: room.name
|
roomName: room.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording() {
|
async function stopRecording() {
|
||||||
return httpRequest("POST", "/recordings/stop", {
|
return httpRequest("POST", "/recordings/stop", {
|
||||||
roomName: room.name
|
roomName: room.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRecording(recordingName) {
|
async function deleteRecording(recordingName) {
|
||||||
const [error, _] = await httpRequest("DELETE", `/recordings/${recordingName}`);
|
const [error, _] = await httpRequest(
|
||||||
|
"DELETE",
|
||||||
|
`/recordings/${recordingName}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!error || error.status === 404) {
|
if (!error || error.status === 404) {
|
||||||
const roomId = await room?.getSid();
|
const roomId = await room?.getSid();
|
||||||
await listRecordings(room?.name, roomId);
|
await listRecordings(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listRecordings(roomName, roomId) {
|
async function listRecordings(roomId) {
|
||||||
const url = "/recordings" + (roomName ? `?roomName=${roomName}` + (roomId ? `&roomId=${roomId}` : "") : "");
|
let url = "/recordings";
|
||||||
const [error, body] = await httpRequest("GET", url);
|
if (roomId) {
|
||||||
|
url += `?roomId=${roomId}`;
|
||||||
|
}
|
||||||
|
const [error, body] = await httpRequest("GET", url);
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
const recordings = body.recordings;
|
const recordings = body.recordings;
|
||||||
showRecordingList(recordings);
|
showRecordingList(recordings);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function listRecordingsByRoom() {
|
|
||||||
const roomName = document.getElementById("room-name").value;
|
|
||||||
await listRecordings(roomName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function httpRequest(method, url, body) {
|
async function httpRequest(method, url, body) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: method !== "GET" ? JSON.stringify(body) : undefined
|
body: method !== "GET" ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(responseBody.errorMessage);
|
console.error(responseBody.errorMessage);
|
||||||
const error = {
|
const error = {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
message: responseBody.errorMessage
|
message: responseBody.errorMessage,
|
||||||
};
|
};
|
||||||
return [error, undefined];
|
return [error, undefined];
|
||||||
}
|
|
||||||
|
|
||||||
return [undefined, responseBody];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.message);
|
|
||||||
const errorObj = {
|
|
||||||
status: 0,
|
|
||||||
message: error.message
|
|
||||||
};
|
|
||||||
return [errorObj, undefined];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [undefined, responseBody];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
const errorObj = {
|
||||||
|
status: 0,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
return [errorObj, undefined];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRecordingList(recordings) {
|
function showRecordingList(recordings) {
|
||||||
const recordingsList = document.getElementById("recording-list");
|
const recordingsList = document.getElementById("recording-list");
|
||||||
|
|
||||||
if (recordings.length === 0) {
|
if (recordings.length === 0) {
|
||||||
recordingsList.innerHTML = "<span>There are no recordings available</span>";
|
recordingsList.innerHTML = "<span>There are no recordings available</span>";
|
||||||
} else {
|
} else {
|
||||||
recordingsList.innerHTML = "";
|
recordingsList.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
recordings.forEach((recording) => {
|
recordings.forEach((recording) => {
|
||||||
const recordingName = recording.name;
|
const recordingName = recording.name;
|
||||||
const recordingSize = formatBytes(recording.size ?? 0);
|
|
||||||
const recordingDate = new Date(recording.startedAt).toLocaleString();
|
|
||||||
|
|
||||||
const recordingContainer = document.createElement("div");
|
const recordingContainer = document.createElement("div");
|
||||||
recordingContainer.className = "recording-container";
|
recordingContainer.className = "recording-container";
|
||||||
recordingContainer.id = recordingName;
|
recordingContainer.id = recordingName;
|
||||||
|
|
||||||
recordingContainer.innerHTML = `
|
recordingContainer.innerHTML = `
|
||||||
<i class="fa-solid fa-file-video"></i>
|
<i class="fa-solid fa-file-video"></i>
|
||||||
<div class="recording-info">
|
<div class="recording-info">
|
||||||
<p class="recording-name">${recordingName}</p>
|
<p class="recording-name">${recordingName}</p>
|
||||||
<p class="recording-size">${recordingSize}</p>
|
|
||||||
<p class="recording-date">${recordingDate}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="recording-actions">
|
<div class="recording-actions">
|
||||||
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
|
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
|
||||||
@ -349,38 +365,29 @@ function showRecordingList(recordings) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
recordingsList.append(recordingContainer);
|
recordingsList.append(recordingContainer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRecording(recordingName) {
|
function displayRecording(recordingName) {
|
||||||
const recordingVideoDialog = document.getElementById("recording-video-dialog");
|
const recordingVideoDialog = document.getElementById(
|
||||||
recordingVideoDialog.showModal();
|
"recording-video-dialog"
|
||||||
const recordingVideo = document.getElementById("recording-video");
|
);
|
||||||
recordingVideo.src = `/recordings/${recordingName}`;
|
recordingVideoDialog.showModal();
|
||||||
|
const recordingVideo = document.getElementById("recording-video");
|
||||||
|
recordingVideo.src = `/recordings/${recordingName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRecording() {
|
function closeRecording() {
|
||||||
const recordingVideoDialog = document.getElementById("recording-video-dialog");
|
const recordingVideoDialog = document.getElementById(
|
||||||
recordingVideoDialog.close();
|
"recording-video-dialog"
|
||||||
|
);
|
||||||
|
recordingVideoDialog.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAllRecordings() {
|
function removeAllRecordings() {
|
||||||
const recordingList = document.getElementById("recording-list").children;
|
const recordingList = document.getElementById("recording-list").children;
|
||||||
Array.from(recordingList).forEach((recording) => {
|
Array.from(recordingList).forEach((recording) => {
|
||||||
recording.remove();
|
recording.remove();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,18 +56,6 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div id="recordings-all">
|
<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>
|
<h2>Recordings</h2>
|
||||||
<div id="recording-list"></div>
|
<div id="recording-list"></div>
|
||||||
<dialog id="recording-video-dialog">
|
<dialog id="recording-video-dialog">
|
||||||
|
|||||||
@ -3,16 +3,20 @@ import express from "express";
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { AccessToken, EgressClient, EncodedFileOutput, EncodedFileType, WebhookReceiver } from "livekit-server-sdk";
|
import {
|
||||||
|
AccessToken,
|
||||||
|
EgressClient,
|
||||||
|
EncodedFileOutput,
|
||||||
|
EncodedFileType,
|
||||||
|
WebhookReceiver,
|
||||||
|
} from "livekit-server-sdk";
|
||||||
import { S3Service } from "./s3.service.js";
|
import { S3Service } from "./s3.service.js";
|
||||||
|
|
||||||
|
// Configuration
|
||||||
const SERVER_PORT = process.env.SERVER_PORT || 6080;
|
const SERVER_PORT = process.env.SERVER_PORT || 6080;
|
||||||
|
|
||||||
// LiveKit configuration
|
|
||||||
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
|
|
||||||
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
|
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
|
||||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
|
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
|
||||||
|
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
|
||||||
const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
|
const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
|
||||||
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
|
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
@ -27,234 +31,231 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
|
||||||
app.post("/token", async (req, res) => {
|
const egressClient = new EgressClient(
|
||||||
const roomName = req.body.roomName;
|
LIVEKIT_URL,
|
||||||
const participantName = req.body.participantName;
|
LIVEKIT_API_KEY,
|
||||||
|
LIVEKIT_API_SECRET
|
||||||
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({ roomJoin: true, room: roomName, roomRecord: true });
|
|
||||||
const token = await at.toJwt();
|
|
||||||
res.json({ token });
|
|
||||||
});
|
|
||||||
|
|
||||||
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
res.status(200).send();
|
|
||||||
});
|
|
||||||
|
|
||||||
const egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
|
||||||
const s3Service = new S3Service();
|
const s3Service = new S3Service();
|
||||||
|
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) => {
|
app.post("/recordings/start", async (req, res) => {
|
||||||
const { roomName } = req.body;
|
const { roomName } = req.body;
|
||||||
|
|
||||||
if (!roomName) {
|
if (!roomName) {
|
||||||
res.status(400).json({ errorMessage: "roomName is required" });
|
return res.status(400).json({ errorMessage: "roomName is required" });
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const activeRecording = await getActiveRecordingByRoom(roomName);
|
const activeRecording = await getActiveRecordingByRoom(roomName);
|
||||||
|
|
||||||
// Check if there is already an active recording for this room
|
// Check if there is already an active recording for this room
|
||||||
if (activeRecording) {
|
if (activeRecording) {
|
||||||
res.status(409).json({ errorMessage: "Recording already started for this room" });
|
return res
|
||||||
return;
|
.status(409)
|
||||||
}
|
.json({ errorMessage: "Recording already started for this room" });
|
||||||
|
}
|
||||||
|
|
||||||
// Use the EncodedFileOutput to save the recording to an MP4 file
|
// Use the EncodedFileOutput to save the recording to an MP4 file
|
||||||
const fileOutput = new EncodedFileOutput({
|
// The room name, time and room ID in the file path help to organize the recordings
|
||||||
fileType: EncodedFileType.MP4,
|
const fileOutput = new EncodedFileOutput({
|
||||||
filepath: `${RECORDINGS_PATH}{room_name}-{room_id}-{time}`
|
fileType: EncodedFileType.MP4,
|
||||||
|
filepath: `${RECORDINGS_PATH}/{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 = {
|
||||||
try {
|
name: egressInfo.fileResults[0].filename.split("/").pop(),
|
||||||
// Start a RoomCompositeEgress to record all participants in the room
|
startedAt: Number(egressInfo.startedAt) / 1_000_000,
|
||||||
const egressInfo = await egressClient.startRoomCompositeEgress(roomName, { file: fileOutput });
|
};
|
||||||
const recording = {
|
res.json({ message: "Recording started", recording });
|
||||||
name: egressInfo.fileResults[0].filename.split("/").pop(),
|
} catch (error) {
|
||||||
startedAt: Number(egressInfo.startedAt) / 1_000_000
|
console.error("Error starting recording.", error);
|
||||||
};
|
res.status(500).json({ errorMessage: "Error starting recording" });
|
||||||
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) => {
|
app.post("/recordings/stop", async (req, res) => {
|
||||||
const { roomName } = req.body;
|
const { roomName } = req.body;
|
||||||
|
|
||||||
if (!roomName) {
|
if (!roomName) {
|
||||||
res.status(400).json({ errorMessage: "roomName is required" });
|
return res.status(400).json({ errorMessage: "roomName is required" });
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const activeRecording = await getActiveRecordingByRoom(roomName);
|
const activeRecording = await getActiveRecordingByRoom(roomName);
|
||||||
|
|
||||||
// Check if there is an active recording for this room
|
// Check if there is an active recording for this room
|
||||||
if (!activeRecording) {
|
if (!activeRecording) {
|
||||||
res.status(409).json({ errorMessage: "Recording not started for this room" });
|
return res
|
||||||
return;
|
.status(409)
|
||||||
}
|
.json({ errorMessage: "Recording not started for this room" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stop the egress to finish the recording
|
// Stop the egress to finish the recording
|
||||||
const egressInfo = await egressClient.stopEgress(activeRecording);
|
const egressInfo = await egressClient.stopEgress(activeRecording);
|
||||||
const file = egressInfo.fileResults[0];
|
const file = egressInfo.fileResults[0];
|
||||||
const recording = {
|
const recording = {
|
||||||
name: file.filename.split("/").pop(),
|
name: file.filename.split("/").pop(),
|
||||||
startedAt: Number(egressInfo.startedAt) / 1_000_000,
|
};
|
||||||
size: Number(file.size)
|
return res.json({ message: "Recording stopped", recording });
|
||||||
};
|
} catch (error) {
|
||||||
res.json({ message: "Recording stopped", recording });
|
console.error("Error stopping recording.", error);
|
||||||
} catch (error) {
|
return res.status(500).json({ errorMessage: "Error stopping recording" });
|
||||||
console.error("Error stopping recording.", error);
|
}
|
||||||
res.status(500).json({ errorMessage: "Error stopping recording" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List recordings
|
||||||
|
app.get("/recordings", async (req, res) => {
|
||||||
|
const roomId = req.query.roomId?.toString();
|
||||||
|
try {
|
||||||
|
const awsResponse = await s3Service.listObjects(RECORDINGS_PATH);
|
||||||
|
let recordings = [];
|
||||||
|
if (awsResponse.Contents) {
|
||||||
|
recordings = awsResponse.Contents.map((recording) => {
|
||||||
|
return {
|
||||||
|
name: recording.Key.split("/").pop(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 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 key = RECORDINGS_PATH + recordingName;
|
||||||
|
const exists = await s3Service.exists(key);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return res.status(404).json({ errorMessage: "Recording not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the recording file from S3
|
||||||
|
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 key = RECORDINGS_PATH + recordingName;
|
||||||
|
const exists = await s3Service.exists(key);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return res.status(404).json({ errorMessage: "Recording not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete the recording file from S3
|
||||||
|
await Promise.all([s3Service.deleteObject(key)]);
|
||||||
|
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) => {
|
const getActiveRecordingByRoom = async (roomName) => {
|
||||||
try {
|
try {
|
||||||
// List all active egresses for the room
|
// List all active egresses for the room
|
||||||
const egresses = await egressClient.listEgress({ roomName, active: true });
|
const egresses = await egressClient.listEgress({ roomName, active: true });
|
||||||
return egresses.length > 0 ? egresses[0].egressId : null;
|
return egresses.length > 0 ? egresses[0].egressId : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error listing egresses.", error);
|
console.error("Error listing egresses.", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/recordings", async (req, res) => {
|
|
||||||
const roomName = req.query.roomName?.toString();
|
|
||||||
const roomId = req.query.roomId?.toString();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const keyStart = RECORDINGS_PATH + (roomName ? `${roomName}-` + (roomId ? roomId : "") : "");
|
|
||||||
const keyEnd = ".mp4.json";
|
|
||||||
const regex = new RegExp(`^${keyStart}.*${keyEnd}$`);
|
|
||||||
|
|
||||||
// List all egress metadata files in the recordings path that match the regex
|
|
||||||
const payloadKeys = await s3Service.listObjects(RECORDINGS_PATH, regex);
|
|
||||||
const recordings = await Promise.all(payloadKeys.map((payloadKey) => getRecordingInfo(payloadKey)));
|
|
||||||
const sortedRecordings = filterAndSortRecordings(recordings, roomName, roomId);
|
|
||||||
res.json({ recordings: sortedRecordings });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error listing recordings.", error);
|
|
||||||
res.status(500).json({ errorMessage: "Error listing recordings" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const getRecordingInfo = async (payloadKey) => {
|
|
||||||
// Get the egress metadata file as JSON
|
|
||||||
const data = await s3Service.getObjectAsJson(payloadKey);
|
|
||||||
|
|
||||||
// Get the recording file size
|
|
||||||
const recordingKey = payloadKey.replace(".json", "");
|
|
||||||
const size = await s3Service.getObjectSize(recordingKey);
|
|
||||||
|
|
||||||
const recordingName = recordingKey.split("/").pop();
|
|
||||||
const recording = {
|
|
||||||
id: data.egress_id,
|
|
||||||
name: recordingName,
|
|
||||||
roomName: data.room_name,
|
|
||||||
roomId: data.room_id,
|
|
||||||
startedAt: Number(data.started_at) / 1000000,
|
|
||||||
size: size
|
|
||||||
};
|
|
||||||
return recording;
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
app.get("/recordings/:recordingName", async (req, res) => {
|
|
||||||
const { recordingName } = req.params;
|
|
||||||
const { range } = req.headers;
|
|
||||||
const key = RECORDINGS_PATH + recordingName;
|
|
||||||
const exists = await s3Service.exists(key);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
res.status(404).json({ errorMessage: "Recording not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the recording file from S3
|
|
||||||
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);
|
|
||||||
res.status(500).json({ errorMessage: "Error getting recording" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const getRecordingStream = async (recordingName, range) => {
|
const getRecordingStream = async (recordingName, range) => {
|
||||||
const key = RECORDINGS_PATH + recordingName;
|
const key = RECORDINGS_PATH + recordingName;
|
||||||
const size = await s3Service.getObjectSize(key);
|
const size = await s3Service.getObjectSize(key);
|
||||||
|
|
||||||
// Get the requested range
|
// Get the requested range
|
||||||
const parts = range?.replace(/bytes=/, "").split("-");
|
const parts = range?.replace(/bytes=/, "").split("-");
|
||||||
const start = range ? parseInt(parts[0], 10) : 0;
|
const start = range ? parseInt(parts[0], 10) : 0;
|
||||||
const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE;
|
const endRange = parts[1]
|
||||||
const end = Math.min(endRange, size - 1);
|
? parseInt(parts[1], 10)
|
||||||
|
: start + RECORDING_FILE_PORTION_SIZE;
|
||||||
|
const end = Math.min(endRange, size - 1);
|
||||||
|
|
||||||
const stream = await s3Service.getObject(key, { start, end });
|
const stream = await s3Service.getObject(key, { start, end });
|
||||||
return { stream, size, start, end };
|
return { stream, size, start, end };
|
||||||
};
|
};
|
||||||
|
|
||||||
app.delete("/recordings/:recordingName", async (req, res) => {
|
|
||||||
const { recordingName } = req.params;
|
|
||||||
const key = RECORDINGS_PATH + recordingName;
|
|
||||||
const exists = await s3Service.exists(key);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
res.status(404).json({ errorMessage: "Recording not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete the recording file and metadata file from S3
|
|
||||||
await Promise.all([s3Service.deleteObject(key), s3Service.deleteObject(`${key}.json`)]);
|
|
||||||
res.json({ message: "Recording deleted" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting recording.", error);
|
|
||||||
res.status(500).json({ errorMessage: "Error deleting recording" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(SERVER_PORT, () => {
|
|
||||||
console.log("Server started on port:", SERVER_PORT);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
S3Client,
|
S3Client,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
HeadObjectCommand
|
HeadObjectCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
// S3 configuration
|
// S3 configuration
|
||||||
@ -11,92 +11,83 @@ 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;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (S3Service.instance) {
|
if (S3Service.instance) {
|
||||||
return S3Service.instance;
|
return S3Service.instance;
|
||||||
}
|
|
||||||
|
|
||||||
this.s3Client = new S3Client({
|
|
||||||
endpoint: S3_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: S3_ACCESS_KEY,
|
|
||||||
secretAccessKey: S3_SECRET_KEY
|
|
||||||
},
|
|
||||||
region: AWS_REGION,
|
|
||||||
forcePathStyle: true
|
|
||||||
});
|
|
||||||
|
|
||||||
S3Service.instance = this;
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(key) {
|
this.s3Client = new S3Client({
|
||||||
try {
|
endpoint: S3_ENDPOINT,
|
||||||
await this.headObject(key);
|
credentials: {
|
||||||
return true;
|
accessKeyId: S3_ACCESS_KEY,
|
||||||
} catch (error) {
|
secretAccessKey: S3_SECRET_KEY,
|
||||||
return false;
|
},
|
||||||
}
|
region: AWS_REGION,
|
||||||
}
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
async headObject(key) {
|
S3Service.instance = this;
|
||||||
const params = {
|
return this;
|
||||||
Bucket: S3_BUCKET,
|
}
|
||||||
Key: key
|
|
||||||
};
|
|
||||||
const command = new HeadObjectCommand(params);
|
|
||||||
return this.run(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObjectSize(key) {
|
async exists(key) {
|
||||||
const { ContentLength: size } = await this.headObject(key);
|
try {
|
||||||
return size;
|
await this.headObject(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getObject(key, range) {
|
async headObject(key) {
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
Range: range ? `bytes=${range.start}-${range.end}` : undefined
|
};
|
||||||
};
|
const command = new HeadObjectCommand(params);
|
||||||
const command = new GetObjectCommand(params);
|
return this.run(command);
|
||||||
const { Body: body } = await this.run(command);
|
}
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObjectAsJson(key) {
|
async getObjectSize(key) {
|
||||||
const body = await this.getObject(key);
|
const { ContentLength: size } = await this.headObject(key);
|
||||||
const stringifiedData = await body.transformToString();
|
return size;
|
||||||
return JSON.parse(stringifiedData);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async listObjects(prefix, regex) {
|
async getObject(key, range) {
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
Prefix: prefix
|
Key: key,
|
||||||
};
|
Range: range ? `bytes=${range.start}-${range.end}` : undefined,
|
||||||
const command = new ListObjectsV2Command(params);
|
};
|
||||||
const { Contents: objects } = await this.run(command);
|
const command = new GetObjectCommand(params);
|
||||||
|
const { Body: body } = await this.run(command);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter objects by regex and return the keys
|
async listObjects(prefix) {
|
||||||
return objects?.filter((object) => regex.test(object.Key)).map((payload) => payload.Key) ?? [];
|
const params = {
|
||||||
}
|
Bucket: S3_BUCKET,
|
||||||
|
Prefix: prefix,
|
||||||
|
};
|
||||||
|
const command = new ListObjectsV2Command(params);
|
||||||
|
return await this.run(command);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteObject(key) {
|
async deleteObject(key) {
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
Key: key
|
Key: key,
|
||||||
};
|
};
|
||||||
const command = new DeleteObjectCommand(params);
|
const command = new DeleteObjectCommand(params);
|
||||||
return this.run(command);
|
return this.run(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(command) {
|
async run(command) {
|
||||||
return this.s3Client.send(command);
|
return this.s3Client.send(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
ai-services/openvidu-live-captions/README.md
Normal file
5
ai-services/openvidu-live-captions/README.md
Normal 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/)
|
||||||
221
ai-services/openvidu-live-captions/src/app.js
Normal file
221
ai-services/openvidu-live-captions/src/app.js
Normal 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;
|
||||||
|
}
|
||||||
94
ai-services/openvidu-live-captions/src/index.html
Normal file
94
ai-services/openvidu-live-captions/src/index.html
Normal 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 |
283
ai-services/openvidu-live-captions/src/styles.css
Normal file
283
ai-services/openvidu-live-captions/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user