javascript
Yesterday

Как я написал свою стриминговую платформу и подружил её с 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. Открываю страницу на телефоне или компе — смотрю, пишу в чат. Никаких облаков, никаких подписок, всё своё.

Мой тестовый IPhone 12 с IOS 27 и стрим который работает в локальной сети

Главный урок: если вы яблочник, тестируйте на iOS сразу. Не ждите пока всё заработает на Android и Chrome. Apple любит делать по-своему, и лучше знать об этом с самого начала.. а так появилось желание доработать этот прототип и создать ядро веб-сервиса и выкинуть его в GitHub