Add recording support (#290)
* WIP add recording support * Add region env var * Add recording indicator * Indicator and support for stopping recording * remove logs * rename server functions
This commit is contained in:
parent
84c6151e4b
commit
ff15c2ee31
13
.env.example
13
.env.example
@ -8,10 +8,21 @@ LIVEKIT_API_SECRET=secret
|
||||
# URL pointing to the LiveKit server.
|
||||
LIVEKIT_URL=wss://my-livekit-project.livekit.cloud
|
||||
|
||||
## PUBLIC
|
||||
# Recording
|
||||
# S3_KEY_ID=
|
||||
# S3_KEY_SECRET=
|
||||
# S3_ENDPOINT=
|
||||
# S3_BUCKET=
|
||||
# S3_REGION=
|
||||
|
||||
# PUBLIC
|
||||
NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token
|
||||
#NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record
|
||||
|
||||
# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters.
|
||||
# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true
|
||||
|
||||
# Optional, to pipe logs to datadog
|
||||
# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token
|
||||
# NEXT_PUBLIC_DATADOG_SITE=datadog-site
|
||||
|
||||
|
||||
30
lib/RecordingIndicator.tsx
Normal file
30
lib/RecordingIndicator.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useIsRecording } from '@livekit/components-react';
|
||||
import * as React from 'react';
|
||||
|
||||
export function RecordingIndicator() {
|
||||
const isRecording = useIsRecording();
|
||||
const [wasRecording, setWasRecording] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRecording !== wasRecording) {
|
||||
setWasRecording(isRecording);
|
||||
if (isRecording) {
|
||||
window.alert('This meeting is being recorded');
|
||||
}
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxShadow: isRecording ? 'red 0px 0px 0px 3px inset' : 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import {
|
||||
MediaDeviceMenu,
|
||||
TrackToggle,
|
||||
useRoomContext,
|
||||
useIsRecording,
|
||||
} from '@livekit/components-react';
|
||||
import styles from '../styles/SettingsMenu.module.css';
|
||||
|
||||
@ -21,16 +22,18 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
export function SettingsMenu(props: SettingsMenuProps) {
|
||||
const layoutContext = useMaybeLayoutContext();
|
||||
const room = useRoomContext();
|
||||
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
|
||||
|
||||
const settings = React.useMemo(() => {
|
||||
return {
|
||||
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
|
||||
effects: { label: 'Effects' },
|
||||
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabs = React.useMemo(
|
||||
() => Object.keys(settings) as Array<keyof typeof settings>,
|
||||
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
|
||||
[settings],
|
||||
);
|
||||
const { microphoneTrack } = useLocalParticipant();
|
||||
@ -62,6 +65,42 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
}
|
||||
}, [isNoiseFilterEnabled, microphoneTrack]);
|
||||
|
||||
const isRecording = useIsRecording();
|
||||
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
|
||||
const [processingRecRequest, setProcessingRecRequest] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialRecStatus !== isRecording) {
|
||||
setProcessingRecRequest(false);
|
||||
}
|
||||
}, [isRecording, initialRecStatus]);
|
||||
|
||||
const toggleRoomRecording = async () => {
|
||||
if (!recordingEndpoint) {
|
||||
throw TypeError('No recording endpoint specified');
|
||||
}
|
||||
if (room.isE2EEEnabled) {
|
||||
throw Error('Recording of encrypted meetings is currently not supported');
|
||||
}
|
||||
setProcessingRecRequest(true);
|
||||
setInitialRecStatus(isRecording);
|
||||
let response: Response;
|
||||
if (isRecording) {
|
||||
response = await fetch(recordingEndpoint + `/stop?roomName=${room.name}`);
|
||||
} else {
|
||||
response = await fetch(recordingEndpoint + `/start?roomName=${room.name}`);
|
||||
}
|
||||
if (response.ok) {
|
||||
} else {
|
||||
console.error(
|
||||
'Error handling recording request, check server logs:',
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
setProcessingRecRequest(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-menu" style={{ width: '100%' }} {...props}>
|
||||
<div className={styles.tabs}>
|
||||
@ -134,6 +173,21 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'recording' && (
|
||||
<>
|
||||
<h3>Record Meeting</h3>
|
||||
<section>
|
||||
<p>
|
||||
{isRecording
|
||||
? 'Meeting is currently being recorded'
|
||||
: 'No active recordings for this meeting'}
|
||||
</p>
|
||||
<button disabled={processingRecRequest} onClick={() => toggleRoomRecording()}>
|
||||
{isRecording ? 'Stop' : 'Start'} Recording
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`lk-button ${styles.settingsCloseButton}`}
|
||||
|
||||
@ -41,3 +41,8 @@ export function randomString(length: number): string {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const sleep = (time: number) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, time);
|
||||
});
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"@datadog/browser-logs": "^5.10.0",
|
||||
"@livekit/components-react": "2.4.3",
|
||||
"@livekit/components-styles": "1.0.12",
|
||||
"@livekit/krisp-noise-filter": "^0.2.0",
|
||||
"@livekit/krisp-noise-filter": "^0.2.5",
|
||||
"livekit-client": "2.4.2",
|
||||
"livekit-server-sdk": "2.6.0",
|
||||
"next": "14.2.5",
|
||||
|
||||
76
pages/api/record/start.ts
Normal file
76
pages/api/record/start.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function startRecording(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { roomName } = req.query;
|
||||
|
||||
/**
|
||||
* CAUTION:
|
||||
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
|
||||
* to start/stop recordings for that room.
|
||||
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
|
||||
*/
|
||||
|
||||
if (typeof roomName !== 'string') {
|
||||
res.statusMessage = 'Missing roomName parameter';
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
LIVEKIT_API_KEY,
|
||||
LIVEKIT_API_SECRET,
|
||||
LIVEKIT_URL,
|
||||
S3_KEY_ID,
|
||||
S3_KEY_SECRET,
|
||||
S3_BUCKET,
|
||||
S3_ENDPOINT,
|
||||
S3_REGION,
|
||||
} = process.env;
|
||||
|
||||
const hostURL = new URL(LIVEKIT_URL!);
|
||||
hostURL.protocol = 'https:';
|
||||
|
||||
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||
|
||||
const existingEgresses = await egressClient.listEgress({ roomName });
|
||||
if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) {
|
||||
(res.statusMessage = 'Meeting is already being recorded'), res.status(409).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOutput = new EncodedFileOutput({
|
||||
filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`,
|
||||
output: {
|
||||
case: 's3',
|
||||
value: new S3Upload({
|
||||
endpoint: S3_ENDPOINT,
|
||||
accessKey: S3_KEY_ID,
|
||||
secret: S3_KEY_SECRET,
|
||||
region: S3_REGION,
|
||||
bucket: S3_BUCKET,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await egressClient.startRoomCompositeEgress(
|
||||
roomName,
|
||||
{
|
||||
file: fileOutput,
|
||||
},
|
||||
{
|
||||
layout: 'speaker',
|
||||
},
|
||||
);
|
||||
|
||||
res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
res.statusMessage = e.name;
|
||||
console.error(e);
|
||||
res.status(500).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
pages/api/record/stop.ts
Normal file
46
pages/api/record/stop.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { EgressClient } from 'livekit-server-sdk';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function stopRecording(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { roomName } = req.query;
|
||||
|
||||
/**
|
||||
* CAUTION:
|
||||
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
|
||||
* to start/stop recordings for that room.
|
||||
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
|
||||
*/
|
||||
|
||||
if (typeof roomName !== 'string') {
|
||||
res.statusMessage = 'Missing roomName parameter';
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env;
|
||||
|
||||
const hostURL = new URL(LIVEKIT_URL!);
|
||||
hostURL.protocol = 'https:';
|
||||
|
||||
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||
const activeEgresses = (await egressClient.listEgress({ roomName })).filter(
|
||||
(info) => info.status < 2,
|
||||
);
|
||||
if (activeEgresses.length === 0) {
|
||||
res.statusMessage = 'No active recording found';
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId)));
|
||||
|
||||
res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
res.statusMessage = e.name;
|
||||
console.error(e);
|
||||
res.status(500).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ import * as React from 'react';
|
||||
import { DebugMode } from '../../lib/Debug';
|
||||
import { decodePassphrase, useServerUrl } from '../../lib/client-utils';
|
||||
import { SettingsMenu } from '../../lib/SettingsMenu';
|
||||
import { RecordingIndicator } from '../../lib/RecordingIndicator';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const router = useRouter();
|
||||
@ -189,6 +190,7 @@ const ActiveRoom = ({ roomName, userChoices, onLeave }: ActiveRoomProps) => {
|
||||
}
|
||||
/>
|
||||
<DebugMode />
|
||||
<RecordingIndicator />
|
||||
</LiveKitRoom>
|
||||
)}
|
||||
</>
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -18,7 +18,7 @@ importers:
|
||||
specifier: 1.0.12
|
||||
version: 1.0.12
|
||||
'@livekit/krisp-noise-filter':
|
||||
specifier: ^0.2.0
|
||||
specifier: ^0.2.5
|
||||
version: 0.2.5(livekit-client@2.4.2)
|
||||
livekit-client:
|
||||
specifier: 2.4.2
|
||||
@ -2281,7 +2281,7 @@ snapshots:
|
||||
eslint: 9.8.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0)
|
||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0)
|
||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
|
||||
eslint-plugin-jsx-a11y: 6.8.0(eslint@9.8.0)
|
||||
eslint-plugin-react: 7.33.2(eslint@9.8.0)
|
||||
eslint-plugin-react-hooks: 4.6.0(eslint@9.8.0)
|
||||
@ -2305,7 +2305,7 @@ snapshots:
|
||||
enhanced-resolve: 5.17.1
|
||||
eslint: 9.8.0
|
||||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
|
||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0)
|
||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
|
||||
fast-glob: 3.3.2
|
||||
get-tsconfig: 4.7.2
|
||||
is-core-module: 2.13.1
|
||||
@ -2327,7 +2327,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0):
|
||||
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0):
|
||||
dependencies:
|
||||
array-includes: 3.1.7
|
||||
array.prototype.findlastindex: 1.2.3
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user