JavaScript: разрабатываем приложение для записи экрана
Привет, друзья!
Хочу поделиться с вами решением интересной задачи: записать экран компьютера пользователя.
Общие требования к реализации:
- запись должна состоять из видео и аудио
- у пользователя должна быть возможность просмотра и скачивания записи
- данные должны передаваться и сохраняться на сервере
- запись, сохраняемая на сервере, должна быть приличного качества, но весить мало
Если вам это интересно, прошу следовать за мной.
→ Исходный код проекта
Скриншот:
К слову, здесь можно почитать о том, как разработать приложение для записи звука.
Основные технологии, которые мы будем использовать при разработке приложения:
Express.js
—Node.js-фреймворк
для разработки веб-серверовReact.js
—JavaScript-фремворк
для разработки пользовательских интерфейсовSocket.io
— библиотека для разработки realtime-приложений с помощью веб-сокетовFFmpeg
— инструмент для работы с видео и аудио
Здесь вы найдете шпаргалку по Express API
, а здесь — руководство по работе с Socket.io
.
Вместо React
вы можете использовать любой другой фреймворк или ванильный JS
. Если хотите, можете использовать TypeScript
.
Подготовка и настройка проекта
mkdir screen-record && cd !$
yarn init -yp
yarn add concurrently
- создаем директорию для проекта и переходим в нее
- инициализируем
Node.js-проект
- устанавливаем
concurrently
Определяем команды для запуска серверов в package.json
:
"scripts": {
"server": "yarn --cwd server dev",
"client": "yarn --cwd client start",
"start": "concurrently \"yarn server\" \"yarn client\""
}
mkdir server && cd !$
yarn init -yp
yarn add express socket.io @ffmpeg-installer/ffmpeg fluent-ffmpeg
yarn add -D nodemon
- создаем директорию для сервера и переходим в нее
- инициализируем
Node.js-проект
- устанавливаем основные зависимости и зависимость для разработки (про
ffmpeg
мы поговорим в разделе, посвященном разработке сервера)
Определяем тип кода сервера и команды для запуска сервера в package.json
:
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
cd .. && yarn create react-app client
- возвращаемся в корневую директорию и создаем шаблон
react-приложения
в директорииclient
cd client
yarn add socket.io-client react-loader-spinner
yarn add -D sass
- переходим в директорию
client
- устанавливаем основные зависимости и зависимость для разработки:
Я также удалил из шаблона все лишнее (инструменты для тестирования, web-vitals
и т.д.).
Единственное, что нужно добавить в package.json
— это адрес сервера для проксирования запросов:
"proxy": "http://localhost:4000",
На этом подготовка и настройка проекта завершены.
Клиент
Весь код клиента содержится в файле scr/App.js
.
import { useEffect, useRef, useState } from 'react'
import Loader from 'react-loader-spinner'
import io from 'socket.io-client'
import './App.scss'
Импортируем хуки, индикатор загрузки, клиента socket.io
и стили.
const SERVER_URI = 'http://localhost:4000'
let mediaRecorder = null
let dataChunks = []
Создаем переменные:
- для адреса сервера
- экземпляра
MediaRecorder
.MediaRecorder
— это интерфейс, предоставляемыйMediaStream Recording API
, для записи медиа - частей записанных данных
function App() {
// TODO
}
export default App
const username = useRef(`User_${Date.now().toString().slice(-4)}`)
const socketRef = useRef(io(SERVER_URI))
const videoRef = useRef()
const linkRef = useRef()
- генерируем случайное имя пользователя (например,
User_1234
) — в реальном приложении имя пользователя, скорее всего, будет извлекаться из объектаuser
, содержащегося в контексте, например,const { user } = useAuthContext(); const { username } = user
- вызов
io(url, options)
возвращает уникальный сокет клиента, используемый для передачи и получения данных от сервера. Нам достаточно указать адрес сервера. С полным списком настроек можно ознакомиться здесь - нам также нужны ссылки на
DOM-элементы
video
иa
для предоставления пользователю возможности просмотра записи и ее скачивания
Мы используем хук useRef
для сохранения состояний между рендерингами.
const [screenStream, setScreenStream] = useState()
const [voiceStream, setVoiceStream] = useState()
const [recording, setRecording] = useState(false)
const [loading, setLoading] = useState(true)
screenStream
— поток видео захваченного экранаvoiceStream
— поток аудио из микрофонаrecording
— индикатор состояния записиloading
— индикатор состояния загрузки
Первое, что нам нужно сделать на клиенте — это уведомить сервер о подключении нового пользователя, сообщив ему имя пользователя:
useEffect(() => {
socketRef.current.emit('user:connected', username.current)
}, [])
Для отправки событий используется метод socket.emit(type, data)
, где type
— строка, обозначающая тип события, а data
— данные. Данными могут быть как примитивы, так и объекты. Для обработки событий используется метод socket.on(type, callback)
, где type
— тип события, а callback
— функция обработки, принимающая данные, отправленные с помощью socket.emit
.
Далее нам необходимо захватить экран (получить поток видеоданных):
useEffect(() => {
;(async () => {
// проверяем поддержку
if (navigator.mediaDevices.getDisplayMedia) {
try {
// получаем поток
const _screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true
})
// обновляем состояние
setScreenStream(_screenStream)
} catch (e) {
console.error('*** getDisplayMedia', e)
setLoading(false)
}
} else {
console.warn('*** getDisplayMedia not supported')
setLoading(false)
}
})()
}, [])
Для получения видеоданных из захвата экрана используется метод getDisplayMedia()
, предоставляемый интерфейсом MediaDevices
, входящим в состав Navigator
.
К сожалению, на сегодняшний день данный метод поддерживается только десктопными браузерами, что составляет около 40% пользователей, но это все же лучше, чем ничего.
Следует отметить, что getDisplayMedia
также умеет захватывать аудиоданные, но в настоящее время эту возможность поддерживают только Edge
и Chrome
, поэтому мы воспользуемся другим интерфейсом.
Примечание: Safari требует, чтобы пользователь явно выразил намерение на захват экрана. Для решения этой проблемы данный блок кода можно поместить в функцию startRecording
(см. ниже).
Получаем аудиоданные из микрофона пользователя:
useEffect(() => {
;(async () => {
// проверяем поддержку
if (navigator.mediaDevices.getUserMedia) {
// сначала мы должны получить видеопоток
if (screenStream) {
try {
// получаем поток
const _voiceStream = await navigator.mediaDevices.getUserMedia({
audio: true
})
// обновляем состояние
setVoiceStream(_voiceStream)
} catch (e) {
console.error('*** getUserMedia', e)
// см. ниже
setVoiceStream('unavailable')
} finally {
setLoading(false)
}
}
} else {
console.warn('*** getUserMedia not supported')
setLoading(false)
}
})()
}, [screenStream])
Для получения аудио-потока используется метод getUserMedia
. С поддержкой данного метода все намного лучше.
Не знаю точно, с чем это связано, но если мы попытаемся получить потоки одновременно, то получим только видеопоток, а попытка получения аудиопотока завершится ошибкой Permission denied
. По крайней мере, такое поведение наблюдается в Chrome
.
Мы готовы писать экран без звука, поэтому при возникновении любой ошибки, связанной с получением аудиопотока (включая отказ пользователя в предоставлении разрешения на использование микрофона), мы устанавливаем voiceStream
в значение unavailable
.
Также обратите внимание на расстановку setLoading(false)
. При инициализации приложения мы показываем пользователю индикатор загрузки до получения всех необходимых разрешений.
Глянем на разметку:
return (
<>
Screen Recording App
Download
>
)
Ничего особенного:
- элемент
video
для просмотра записи - элемент
a
для скачивания записи - кнопка для запуска и остановки записи
Метод onClick
выглядит так:
const onClick = () => {
if (!recording) {
startRecording()
} else {
if (mediaRecorder) {
mediaRecorder.stop()
}
}
}
Операция, выполняемая при нажатии кнопки, зависит от значения recording
.
function startRecording() {
if (screenStream && voiceStream && !mediaRecorder) {
// TODO
}
}
Для выполнения кода функции startRecording
требуется наличие потоков и отсутствие экземпляра MediaRecorder
.
// обновляем состояние
setRecording(true)
// удаляем атрибуты
videoRef.current.removeAttribute('src')
linkRef.current.removeAttribute('href')
linkRef.current.removeAttribute('download')
Формируем медиа-поток:
let mediaStream
if (voiceStream === 'unavailable') {
mediaStream = screenStream
} else {
mediaStream = new MediaStream([
...screenStream.getVideoTracks(),
...voiceStream.getAudioTracks()
])
}
Состав медиа-потока зависит от доступности аудиопотока. Если аудиопоток недоступен, медиапоток будет состоять только из видеопотока. Иначе формируется объединенный поток из видеотреков видеопотока и аудиотреков аудиопотока. Для объединения потоков используется интерфейс MediaStream
. С его поддержкой все хорошо.
Существует также другой способ объединения потоков:
const audioTracks = voiceStream.getAudioTracks()
audioTracks.forEach(track => {
screenStream.addTrack(track)
})
mediaStream = screenStream
mediaRecorder = new MediaRecorder(mediaStream)
mediaRecorder.ondataavailable = ({ data }) => {
dataChunks.push(data)
socketRef.current.emit('screenData:start', {
username: username.current,
data
})
}
mediaRecorder.onstop = stopRecording
mediaRecorder.start(250)
Создаем экземпляр MediaRecorder
.
Метод start
принимает количество мс. По истечении указанного времени вызывается событие dataavailable
. Данные содержатся в свойстве data
.
Мы помещаем части записанных данных в массив dataChunks
и отправляем их на сервер с помощью сокета. В данном случае мы делаем это 4 раза в секунду.
По окончанию записи вызывается функция stopRecording
:
function stopRecording() {
// обновляем состояние
setRecording(false)
// сообщаем серверу о завершении записи
socketRef.current.emit('screenData:end', username.current)
// об этом хорошо написано здесь: https://learn.javascript.ru/blob
// дополнительно:
// https://developer.mozilla.org/en-US/docs/Web/API/Blob
// https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
const videoBlob = new Blob(dataChunks, {
type: 'video/webm'
})
const videoSrc = URL.createObjectURL(videoBlob)
// источник видео
videoRef.current.src = videoSrc
// ссылка для скачивания файла
linkRef.current.href = videoSrc
// название скачиваемого файла
linkRef.current.download = `${Date.now()}-${username.current}.webm`
// выполняем сброс
mediaRecorder = null
dataChunks = []
}
На этом с клиентом мы закончили.
Сервер
Структура проекта (директория server
):
- socket_io
- onConnection.js - функция для обработки подключения
- utils
- saveData.js - функция для сохранения записи
- video - директория для записей
- index.js - код сервера
- ...
Начнем с файла, содержащего код сервера (index.js
):
import express from 'express'
import http from 'http'
import { Server } from 'socket.io'
import { onConnection } from './socket_io/onConnection.js'
Импортируем библиотеки и функцию для обработки подключения.
const app = express()
const server = http.createServer(app)
const io = new Server(server, {
cors: {
origin: 'http://localhost:3000'
}
})
Создаем экземпляры express-приложения
, сервера и socket.io
. Обратите внимание на настройку cors
. Данная настройка является обязательной.
io.on('connection', onConnection)
server.listen(4000, () => {
console.log('Server ready