Как я написал свою стриминговую платформу и подружил её с iOS
Сидел я как-то вечером один, и пришла в голову мысль: а почему бы не написать свою платформу для стримов? Не для чего-то глобального, а так — чисто идея, проверить, как оно работает изнутри. Ну и заодно чтобы стримить PUBG Mobile. Просто для себя.
Серверная часть
Начал с сервера. Нужно было принимать RTMP-поток — это стандарт для стриминга, его понимает почти любое софт для вещания. Я стримил из PRISM Live, оно умеет отправлять RTMP.
Взял библиотеку node-media-server. Базовая конфигурация выглядит так:
const config = {
rtmp: {
port: 1935,
auth: {
play: false,
publish: true,
secret: 'test'
}
},
http: {
port: 8000,
mediaroot: path.join(__dirname, 'media')
}
};Порт 1935 для приёма видео, порт 8000 для раздачи файлов зрителям, авторизация по ключу чтобы кто попало не стримил. Просмотр без пароля.
Дальше встал вопрос: как видео попадёт в браузер? RTMP браузеры не понимают, им нужен HLS — это когда поток нарезается на маленькие кусочки и отдаётся как обычные файлы. Можно было включить HLS прямо в настройках node-media-server, но тогда задержка была бы секунд 10-20. Это много.
Поэтому я пошёл другим путём. Когда стример подключается, сервер получает событие postPublish. В этот момент я запускаю ffmpeg и руками задаю параметры кодирования:
nms.on('postPublish', (id, streamPath, args) => {
const streamName = streamPath.split('/').pop();
const hlsDir = path.join(__dirname, 'media', 'live', streamName);
if (!fs.existsSync(hlsDir)) {
fs.mkdirSync(hlsDir, { recursive: true });
}
const ffmpegArgs = [
'-i', `rtmp://localhost:1935${streamPath}`,
'-c:v', 'libx264',
'-preset', 'veryfast',
'-tune', 'zerolatency',
'-g', '30',
'-c:a', 'aac',
'-f', 'hls',
'-hls_time', '1',
'-hls_list_size', '2',
'-hls_flags', 'delete_segments+program_date_time',
path.join(hlsDir, 'index.m3u8')
];
const ffmpeg = spawn('/usr/bin/ffmpeg', ffmpegArgs);
nms.ffmpegProcesses[streamPath] = ffmpeg;
});Ключевые моменты тут: -hls_time 1 — сегменты по одной секунде, -hls_list_size 2 — храним только два последних сегмента, -tune zerolatency — минимальная задержка. Плюс автоочистка старых сегментов через delete_segments.
Важно было не забыть убивать ffmpeg когда стрим заканчивается. Без этого процессы зависают и жрут память:
nms.on('donePublish', (id, streamPath, args) => {
if (nms.ffmpegProcesses && nms.ffmpegProcesses[streamPath]) {
nms.ffmpegProcesses[streamPath].kill();
delete nms.ffmpegProcesses[streamPath];
}
});Чат сделал на socket.io на отдельном порту 4000. Хранение в оперативке, массив на 100 сообщений:
const messages = {};
io.on('connection', (socket) => {
socket.on('join', (streamPath) => {
socket.join(streamPath);
if (messages[streamPath]) {
socket.emit('history', messages[streamPath]);
}
});
socket.on('message', (data) => {
const msg = {
id: Date.now(),
username: data.username,
text: data.text,
time: new Date().toLocaleTimeString()
};
if (!messages[data.streamPath]) messages[data.streamPath] = [];
messages[data.streamPath].push(msg);
if (messages[data.streamPath].length > 100) {
messages[data.streamPath].shift();
}
io.to(data.streamPath).emit('message', msg);
});
});Клиентская часть
Фронтенд писал на React. Страница зрителя: слева видео, справа чат.
Самая интересная часть — выбор протокола для видео. Не все браузеры одинаковы, и тут всплыла главная боль.
Вот как выглядит логика плеера:
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const hasMediaSource = 'MediaSource' in window && typeof MediaSource === 'function';
if (hasMediaSource) {
const player = flvjs.createPlayer({
type: 'flv',
url: FLV_URL,
isLive: true,
hasAudio: true,
hasVideo: true
});
player.attachMediaElement(video);
player.load();
return () => player.destroy();
} else {
video.src = HLS_URL;
video.play().catch(() => {});
return () => {
video.pause();
video.src = '';
};
}
}, []);Логика такая: если браузер поддерживает MediaSource Extensions — используем flv.js для FLV-потока. Это даёт задержку 1-3 секунды. Если нет — даём видео-тегу прямую ссылку на HLS.
Главная боль: iOS
Я тестировал на десктопном Chrome и на Android — всё работало отлично. Но я сам яблочник, пользуюсь iPhone. Открываю свою платформу на телефоне — чёрный экран.
Проблема в том, что iOS Safari не поддерживает MediaSource Extensions для FLV. flv.js просто падает. Пришлось допиливать фолбэк на HLS, который Safari понимает нативно. Задержка стала 3-5 секунд вместо 1-3, но хоть заработало.
Дальше вторая проблема: пытаюсь написать сообщение в чат с телефона, открывается клавиатура — и вся вёрстка ломается. Видео уезжает вверх, интерфейс перекрывается. iOS Safari своеобразно работает с viewport и position fixed. Начал с простого, просто добавил метатег:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
width=device-width чтобы страница подстраивалась под ширину экрана. maximum-scale=1.0 и user-scalable=no чтобы запретить зум — на стриминговой платформе он только мешает, случайно увеличил экран и всё, интерфейс поплыл.
Но метатег сам по себе не спас от клавиатурного ада. Поэтому дальше в дело пошёл js-костыль, а если быть точнее, то спасло API window.visualViewport:
useEffect(() => {
if (!window.visualViewport) return;
const handleResize = () => {
window.scrollTo(0, 0);
const layout = document.querySelector('.stream-layout');
if (layout) {
layout.style.height = `${window.visualViewport.height}px`;
}
};
window.visualViewport.addEventListener('resize', handleResize);
window.visualViewport.addEventListener('scroll', handleResize);
return () => {
window.visualViewport.removeEventListener('resize', handleResize);
window.visualViewport.removeEventListener('scroll', handleResize);
};
}, []);Эта штука показывает реальный размер экрана с учётом всплывшей клавиатуры. При каждом изменении я принудительно задаю высоту контейнера. Плюс window.scrollTo(0, 0) чтобы сбросить системный скролл Safari. Выглядит как костыль, но работает.
Третья проблема: iOS блокирует автовоспроизведение со звуком. Поэтому видео-тег:
<video ref={videoRef} autoPlay muted playsInline controls />muted чтобы видео запустилось, playsInline чтобы iOS не разворачивал плеер на весь экран, controls чтобы зритель мог сам включить звук.
Что получилось
Своя платформа для стримов. Я запускаю PRISM Live, стримлю PUBG. Открываю страницу на телефоне или компе — смотрю, пишу в чат. Никаких облаков, никаких подписок, всё своё.
Главный урок: если вы яблочник, тестируйте на iOS сразу. Не ждите пока всё заработает на Android и Chrome. Apple любит делать по-своему, и лучше знать об этом с самого начала.. а так появилось желание доработать этот прототип и создать ядро веб-сервиса и выкинуть его в GitHub