Друг в другом городе, вы оба хотите досмотреть сезон. Обычно это заканчивается одним из двух сценариев. Либо демонстрация экрана в Discord: картинка мылится, звук сжат до стерео, а у второго зрителя всё безбожно фризит. Либо обратный отсчёт «на счёт три жмём плей», после чего через десять минут выясняется, что у одного диалог уже на пару секунд впереди.
Я сделал совместный просмотр в своём self-hosted медиацентре Avalon MediaCard. У каждого участника идёт оригинальный поток без пережатия напрямую с сервера, а бэкенд следит только за тем, чтобы все зрители находились ровно в одной точке фильма. А если это сериал, комната никуда не исчезает: она сохраняется вместе с текущей серией и секундой, чтобы в следующий раз можно было продолжить ровно с того же места.
С первого раза гладко это не заработало: в процессе отладки я поймал баг, который превращал просмотр в слайд-шоу из одного кадра в секунду. О том, как устроена эта система снаружи, почему микроускорение лучше перемотки и на какие грабли я наступил под капотом рассказываю в статье.
Комната, лобби и шесть цифр на пульте
Всё начинается с создания комнаты. Хост выбирает фильм или сериал, задаёт название и выбирает режим управления. Режимов два: строгий (ставить на паузу и перематывать может только ведущий) и демократичный (управлять просмотром может любой зритель).
Затем хост выбирает источник видео: конкретную озвучку, дорожку, качество. Я намеренно оставил выбор источника только за хостом. Если дать каждому выбирать файл самому, один выберет версию без цензуры, второй дублированную с другим таймингом, и магия синхронного просмотра развалится на первой же минуте.

Гостям комнаты технические детали не нужны. Им не нужно переходить по длинным ссылкам: хост делится простым шестизначным PIN-кодом. Это осознанное решение для гостиных: ссылку с пульта Android TV набирать неудобно, а шесть цифр вводятся за пару секунд на экранной клавиатуре.
В лобби зритель видит список подключившихся и может переключить свой статус готовности: отошёл за попкорном, слушаю на фоне или готов начинать.

Когда хост жмёт старт, плеер открывается одновременно у всех. Внутри плеера есть текстовый чат, всплывающие эмодзи-реакции и выдвижная панель участников, где видно, кто прямо сейчас смотрит видео, а кто застрял на буферизации.

Комната не уничтожается после закрытия вкладки или выключения телевизора. Она остаётся в общем списке на главном экране. В неё можно вернуться в любой момент без повторного ввода PIN-кода. Комната помнит сезон, серию и точный таймкод. Для длинных сериалов это идеальный сценарий: посмотрели три серии вечером, закрыли приложение, а в следующие выходные открыли ту же комнату и сразу продолжили с нужной секунды.

Интерфейс и плеер работают одинаково на трёх платформах: в браузере (WebAssembly), на ПК (десктопный клиент) и на смарт-ТВ с Android TV.
Синхронизация по времени, а не по кадрам
Видео у всех одно и то же, но движков воспроизведения четыре: HTML5 <video> в браузере, libmpv на десктопе, Media3 (ExoPlayer) и mpv на Android. У каждого движка своя логика наполнения буфера, своя скорость холодного старта и разные задержки в декодере. Сравнивать между ними декодированные кадры или байты в памяти бессмысленно.
Общий знаменатель у них один: позиция фильма в миллисекундах.
Поэтому сервер совместного просмотра спроектирован как виртуальные часы. Бэкенд не гоняет через себя тяжёлое видео. Он держит в памяти опорную точку: «в серверный момент времени X видеопоток находился на позиции P».
Где должен находиться фильм прямо сейчас, каждый клиент вычисляет сам:
позиция(t) = опорная_позиция + (текущее_время − опорное_время)
Сервер отвечает за распределение времени и управление состояниями, а клиент непрерывно сравнивает показания своего плеера с виртуальными часами комнаты.
Плавный разгон вместо дёрганых перемоток
Что делает наивный плеер, если замечает, что отстал от сервера на 200 миллисекунд? Делает вызов seek().
В реальном плеере это выглядит ужасно: изображение на долю секунды замирает, буфер декодера сбрасывается, звук заикается, и начинается подгрузка сегмента. А поскольку сеть на разных устройствах дышит неравномерно, такие микроперемотки начинают идти сериями каждые несколько секунд, превращая фильм в дерганое месиво.
Я пошёл по пути микрорегулирования скорости. Клиент проверяет расхождение дважды в секунду (каждые 500 мс) и делит ситуацию на три диапазона:
Мёртвая зона (до 30 мс). 30 миллисекунд — это длительность ровно одного кадра при стандартных 30 fps (33 мс). Пытаться ловить доли кадра вредно: человеческий глаз этого не замечает, а лишние команды только нагружают плеер. Скорость остаётся строго 1.0x.
Зона плавной подстройки (от 30 мс до 4 секунд). Если плеер отстал на 100–200 мс, он не делает перемотку. Он плавно повышает скорость до 1.01x…1.05x. Если отставание больше — может разогнаться до 1.25x (или притормозить до 0.85x, если убежал вперёд). Современные звуковые движки поддерживают pitch-correction (сохранение тональности), поэтому зритель на слух вообще не понимает, что темп изменился. За несколько секунд плеер бесшовно нагоняет сервер и возвращается на 1.0x.
Критический дрейф (более 4 секунд). Если кто-то надолго выпал из сети, догонять на повышенной скорости придётся слишком долго. Только в этом случае плеер делает один жёсткий
seek(). Но с обязательным кулдауном в 4 секунды: пока плеер не подгрузит ключевой кадр и не стабилизирует поток, повторные команды на прыжок блокируются.
Барьер готовности перед стартом
Если просто разослать всем команду «включаем через секунду», синхронного старта не получится. Одному устройству нужно 500 мс, чтобы открыть видео, а слабый телевизор может парсить аудиодорожки и сетевые сегменты 3–4 секунды. Результат первый зритель уже смотрит сцену, а второй только инициализирует декодер.
Перед началом просмотра комната переходит в фазу подготовки. Сервер замораживает виртуальные часы и ждёт индивидуального отчёта от каждого участника: «поток открыт, первый кадр в памяти, буфер наполнен». На случай если у кого-то намертво завис интернет или закрылся браузер, на сервере крутится сторожевой таймер на две минуты.
Как только все участники подтвердили готовность, сервер рассчитывает точный момент старта с упреждением: текущее время + 1.5 секунды. Все плееры заранее встают на опорную позицию, замирают на паузе и ровно в назначенную миллисекунду одновременно стартуют по своим часам.
Точно так же устроена пауза посреди фильма: если у одного зрителя внезапно просел канал и опустел буфер, комната встаёт на паузу и даёт ему 10 секунд форы (Grace-Period). Если связь восстановилась воспроизведение плавно продолжается. Если нет отставший временно исключается из синхронизации, чтобы не держать всю компанию, и догоняет остальных уже позже.
История одного бага: фильм со скоростью один кадр в секунду
Когда я только разрабатывал протокол синхронизации, состояние комнаты на сервере описывалось примитивным булевым флагом: isPlaying: true / false. Это привело к очень зрелищному багу.
Хост перематывает фильм на 30-ю минуту. Сервер честно выставляет флаг isPlaying = true и назначает старт через пару секунд. Клиент начинает скачивать сегменты новой сцены и добросовестно рапортует серверу: «я буферизуюсь». Сервер видит, что комната вроде бы играет, а один из клиентов буферизуется решает, что произошла сетевая авария, и аварийно жмёт паузу для всех. За секунду до запланированного пуска!
Дальше клиенты докачивают кусок, сервер назначает новый старт, плеер получает команду и делает seek() на позицию… на которой он уже стоит. Браузерный движок Media Source Extensions (MSE) при повторной перемотке на то же самое место аварийно сбрасывает сетевой конвейер и отменяет активную загрузку следующего чанка видео. Плеер снова уходит в буфер, сервер снова ставит паузу…
Получилась идеальная автоколебательная петля. Каждые полторы секунды видео показывало ровно один кадр, дёргалось, отменяло загрузку и замирало. На экране это выглядело как оживший диафильм.
Этот баг заставил меня полностью выкинуть примитивные флаги и переписать ядро синхронизации на строгий конечный автомат (FSM), где фаза подготовки изолирована от аварийной паузы, а клиент никогда не дёргает перемотку, если уже стоит на нужной секунде.
Где посмотреть
Медиацентр полностью self-hosted и упакован в Docker-контейнер. Клиенты работают в браузере (Kotlin/Wasm), на десктопе (Linux, Windows, macOS) и на Android / Android TV.
Исходный код открыт на GitHub: https://github.com/ensodai/avalon-media-card
Если вы делали совместный просмотр или синхронизацию плееров в своих проектах делитесь в комментариях, будет здорово сравнить подходы!
Технические подробности: архитектура синхронизации и набитые шишки
1. Транспорт и потоки данных
Стек сервера: Ktor 3.x, Kotlin Coroutines, Exposed ORM.
RPC вместо чистого WebSocket: Вместо ручного разбора JSON через сырые сокеты мы используем библиотеку
kotlinx-rpc. Сетевой интерфейсWatchPartyRpcServiceстрого типизирован: клиенты вызывают обычные suspend-методы, а события комнаты слушают через стандартные корутинныеFlow.In-Memory сессия: Состояние активной комнаты (
WatchRoomSession) содержится в оперативной памяти под корутиннымMutex. Никаких блокирующих транзакций в базу данных при событиях воспроизведения. В SQLite пишутся только создание, закрытие комнат и периодический сброс прогресса раз в 5 секунд.Буферизация шины событий: Рассылка событий клиентам идёт через
MutableSharedFlowс параметромonBufferOverflow = BufferOverflow.DROP_OLDEST(ёмкость 64 сообщения). Медленный зритель с высоким пингом не может подвесить серверные корутины других участников.
2. Сверка часов (SNTP поверх WebSocket)
Чтобы клиент мог ориентироваться на опорное время сервера, часы устройств нужно согласовать. Брать дату из HTTP-заголовков бесполезно: задержка рукопожатия TCP даёт погрешность до секунды.
Мы используем протокол из четырёх меток времени внутри постоянного WebSocket-соединения:
T₁- клиент отправил пинг;T₂- сервер принял пакет;T₃- сервер отправил ответ;T₄- клиент принял ответ.
RTT = (T₄ − T₁) − (T₃ − T₂) theta = ((T₂ − T₁) + (T₃ − T₄)) / 2
Для фильтрации сетевого джиттера используется алгоритм Кристиана: из серии замеров (5 на старте, по 3 каждые 30 секунд) отбирается пакет с минимальным RTT он гарантированно прошёл сеть без задержек в очередях роутеров. Полученное смещение theta сглаживается через экспоненциальное скользящее среднее (EMA с коэффициентом α = 0.2):
val filteredMs = (0.8 * prevOffsetMs + 0.2 * rawOffsetMs).toLong()
3. Алгоритм дрифт-контроллера
Фоновый цикл проверки дрейфа запускается на клиенте каждые 500 мс:
val currentPosMs = underlyingController.getCurrentPositionMs() val serverNow = clockSync.estimatedServerTime() val timePassedMs = (serverNow - state.anchorServerTime).inWholeMilliseconds val targetPositionMs = state.anchorPositionMs + timePassedMs val deltaMs = targetPositionMs - currentPosMs when { // 1. Мёртвая зона: меньше одного кадра при 30 fps abs(deltaMs) <= 30L -> { underlyingController.setPlaybackRate(1.0f) } // 2. Критическое расхождение: жёсткая перемотка с кулдауном в 4 секунды abs(deltaMs) > 4000L -> { val now = clockSync.clockProvider() if (lastHardSeekTime == null || (now - lastHardSeekTime) >= 4.seconds) { lastHardSeekTime = now safeSeek(targetPositionMs / 1000.0) underlyingController.setPlaybackRate(1.0f) } } // 3. П-регулятор микроскорости (Kp = 0.0001) else -> { val computedRate = 1.0f + (0.0001f * deltaMs) underlyingController.setPlaybackRate(computedRate.coerceIn(0.85f, 1.25f)) } }
4. Конечно-автоматная модель (6 состояний)
Жизненный цикл сессии в WatchRoomSession разбит на 6 фаз (WatchRoomPhase):
LOBBY- выбор контента, лобби участников.PREPARING- барьер готовности потока. Сторожевой таймер 120 секунд.STARTING_SCHEDULED- преролл. Запланирован пуск через 1.5 секунды (или через 2.0 секунды при перемотке). Виртуальные часы зафиксированы. Отчёты о буферизации в этой фазе не вызывают паузу, а лишь учитываются сервером.PLAYING_IN_SYNC- активное воспроизведение, виртуальное время идёт вперёд.PARTIAL_BUFFERING- аварийная остановка из-за буферизации участника (Grace-Period 10 секунд).FORCE_PAUSED- ручная пауза.
5. Инженерные грабли: чему нас научила разработка
Грабли 1: Бесконечные отмены сегментов (Seek Thrashing)
В браузерном плеере на базе Wasm и Media Source Extensions при вызове video.currentTime = pos движок браузера немедленно прерывает текущий HTTP-запрос чанка (в логах консоли net::ERR_ABORTED).
Если контроллер дрейфа продолжает вызывать seek() во время буферизации, плеер физически не успевает скачать ни одного сегмента до конца. За 10 минут тестов мы получили 124 000 строк отмен в логах.
Защита:
Инвариант дрифт-контроллера: если плеер находится в состоянии буферизации (
isBuffering == true), цикл дрейфа не имеет права менять скорость или вызыватьseek().Идемпотентность перемотки:
if (abs(currentPlayerSec - targetSec) < 0.25) returnЕсли плеер уже находится в пределах 250 мс от цели, повторный
seek()блокируется.
Грабли 2: Асинхронная дыра при переключении серий
В сериале хост переключает серию с 3-й на 4-ю. Сервер переводит комнату в PREPARING и сбрасывает позицию в 0:00. Клиенты начинают запрашивать новый поток через плагины источников этот сетевой резолв занимает от 1.5 до 3 секунд.
В течение этих трёх секунд в плеере хоста всё ещё физически крутится старое видео предыдущей серии на 45-й минуте. Монитор буфера хоста видел, что плеер не буферизует и длительность больше нуля, и отправлял на сервер отчёт: ReportMediaReady(positionMs = 2700000). Сервер ошибочно принимал старый таймкод за позицию новой серии, перезаписывал стартовую точку и запускал комнату. Когда зрители наконец открывали 4-ю серию, плееры пытались прыгнуть на 45-ю минуту несуществующей длительности и намертво висли.
Защита:
На клиенте добавлен флаг барьера
isAwaitingNewStream = true. При смене серии все фоновые отчёты о готовности блокируются на корню до тех пор, пока старый контроллер плеера не будет полностью уничтожен, а новый экземпляр не выдаст первый кадр.На сервере в фазе
PREPARINGпозиция комнаты аппаратно защищена от перезаписи: любые входящие таймкоды от клиентов отсекаются, а точка старта удерживается строго на0L.
Грабли 3: Почему серверный Ktor, а не P2P WebRTC DataChannel
Для синхронизации плееров часто предлагают WebRTC DataChannel. На практике в self-hosted медиацентре P2P создаёт массу проблем:
Необходимость поднимать STUN/TURN серверы для пробития симметричных NAT у домашних провайдеров.
При выходе хоста из комнаты связь рвётся, требуется сложный консенсус выбора нового хоста среди браузеров.
Ktor-сервер выступает единым источником правды: новый зритель подключается по 6 цифрам PIN-кода в любой момент сессии, за 1 сетевой круг получает актуальное состояние и не зависит от браузеров других участников.

