Рабочий прототип ленты вертикального видео (reels/shorts): Next.js (App Router) + React 19 + TypeScript + Zustand.
Живая версия: https://dropex7.github.io/simple-video-feed/ (статический экспорт, деплой через GitHub Actions).
Документ устроен так: сначала краткое описание того, как работает прототип, затем полный план реализации (№1–9), где в каждой секции блоком «В прототипе» отмечено, что сделано как задумано, что сознательно упрощено и что оставлено для продовской версии.
npm install
npm run devapp/page.tsx — первая страница ленты рендерится на сервере
app/v/[id]/page.tsx — отдельная статическая страница ролика с OG-тегами
components/Feed.tsx — лента: медиа-рантайм вне React (refs), DOM-окно −3/+5
со спейсерами, пагинация, делегированные жесты
components/Slide.tsx— слайд: подписка на activeIndex через селектор, скраббер
hooks/ — useIntersectionCandidate (кандидат + подгрузка),
useScrollEnd (воспроизведение, фолбэк с дебаунсом),
useProgress (прогресс-бар мимо стора через rVFC),
useVisibilityPause, useKeyboardNav
utils/media.ts — applyMediaWindow (окно −1/+3), attachMedia/releaseMedia
(явное освобождение декодера), playWithSound
constants/feed.ts — размеры окон, пагинация, пороги и тайминги
lib/store.ts — Zustand: только редкие события (activeIndex, paused)
lib/videos.ts — каталог роликов и «курсорная» пагинация
Жизненный цикл видео (utils/media.ts). Реальный src в разметке не задаётся — только data-src. Видео проходит состояния: COLD (в DOM, src пуст) → WARM (в медиа-окне −1/+3 от активного: src привязан, preload="metadata" — качается префикс, не файл) → ACTIVE (playWithSound, preload="auto") → снова WARM → COLD (releaseMedia: removeAttribute('src') + load() — без load() Safari держит декодер). Позиции просмотра при разрядке уходят в LRU-карту (кап 300), при возврате назад ролик продолжает с того же кадра, а байты приходят из HTTP-кэша.
Два сигнала активации. IntersectionObserver (threshold 0.6, один стабильный обсервер) даёт кандидата — по нему двигается медиа-окно и стартует подгрузка. scrollend даёт активного — по нему вызывается play(). Поэтому при быстром флике подгрузка идёт, а воспроизведение не дёргается. Фолбэк без scrollend — scroll-листенер, который лишь пишет метку времени, тишину (120 мс) проверяет rAF-цикл (hooks/useScrollEnd.ts). Индекс на scrollend считается от scrollTop — это покрывает прыжок скроллбаром в область спейсера, где обсервер молчит.
Два вложенных окна. Медиа-окно −1/+3 (src и декодеры) вложено в DOM-окно −3/+5 (что отрендерено). Всё вне DOM-окна схлопнуто в два спейсера — слайды одной высоты, замеры не нужны. Инвариант: слайд, покидающий DOM, уже освобождён медиа-окном, unmount безопасен.
Состояние. В Zustand только редкие события: activeIndex, paused (18 строк). Всё, что меняется 60 раз в секунду или держит декодер, живёт в refs вне React (Feed.tsx: Map видео-элементов, позиции, кандидат). Прогресс-бар пишется напрямую в CSS-переменную через requestVideoFrameCallback, мимо любого стора (hooks/useProgress.ts). Слайд подписывается селектором s.activeIndex === index — при переходе перерисовываются два слайда плюс контейнер.
Звук и жесты. Автоплей пробуется со звуком; при NotAllowedError — старт в mute, первый тап включает звук (playWithSound/unlockSound). Тап — звук/пауза одним делегированным обработчиком на контейнере; скраббер с hit-area 44 px перематывает драгом (Pointer Events + setPointerCapture). Десктоп: стрелки, пробел.
Страница ролика /v/[id]: свой URL для шеринга, OG-теги, видео без клиентского JS, все страницы каталога отрендерены статически.
test-videos.co.uk — публичные прямые MP4 (H.264, Big Buck Bunny / Jellyfish / Sintel) без ключей и регистрации, все ссылки проверены.
Допущения: ролики 5–60 с, вертикальные, UGC, без DRM. Основная платформа — мобильный веб (iOS Safari, Android Chrome), десктоп должен работать, но не приоритет. Лента бесконечная, курсорная пагинация. Бэкенд отдаёт транскодированную лестницу качеств через CDN.
Next.js (App Router) + React 19 + TypeScript. SSR первой карточки и отдельной страницы ролика, дальше всё клиентское.
Плеер свой, поверх нативного <video>; hls.js (light build, ~30 КБ) там, где нет нативного HLS. Готовые плееры (video.js, Plyr) не беру: это в первую очередь UI-обвязка, а UI здесь и есть продукт, при этом низкоуровневый контроль буфера у них спрятан за абстракциями. И 100–300 КБ на каждый.
Форматы. Контейнер CMAF/fMP4. Дальше по длительности:
- до ~15 секунд — progressive MP4 с
+faststart, один range-запрос. Вместо цепочки манифест → плейлист вариантов → init → медиа-сегмент это экономит 2–3 round-trip до первого кадра, на 3G примерно полсекунды. ABR внутри десяти секунд всё равно не успевает сработать. - длиннее — HLS, сегменты по 2 секунды, ABR.
Кодеки. H.264 базовой дорожкой, HEVC на Apple, AV1 — только если MediaCapabilities.decodingInfo() вернул powerEfficient: true. Софтверный AV1 на бюджетном Android сожрёт батарею и уронит fps сильнее, чем сэкономит трафика.
Лестница 360/480/720/1080, потолок по devicePixelRatio × ширина экрана: на DPR 2 отдавать 1080p некуда.
Пул <video>. Элементы не создаются и не уничтожаются вместе с React-компонентом — есть пул на 3–5 штук, переиспользуются как ячейки в RecyclerView. Причина: на Android ограничено число одновременных аппаратных декодеров, при превышении видео просто не стартует, без внятной ошибки. Точный потолок плавает по чипсетам, я бы замерил на стенде из нескольких бюджетных устройств. Освобождение только явное:
el.pause();
el.removeAttribute('src');
el.load(); // без load() Safari держит декодер
hls?.destroy();Автоплей и звук. playsinline preload="none". Без playsinline iOS уходит в фуллскрин и лента разваливается. preload="none", потому что буферизацией управляем сами (№3). play() возвращает промис, который обязательно ловим: AbortError (pause прервал незавершённый play — типовое при быстром скролле) глушим, NotAllowedError — стартуем в mute и включаем звук по первому жесту. Звук всегда включён, отдельного переключателя нет.
Постер: blurhash из метаданных ленты рисуется мгновенно, поверх постер с CDN, поверх видео. Все три слоя одного размера, чёрного кадра и сдвига layout не возникает.
В прототипе. Стек, свой плеер поверх нативного
<video>,playsinline preload="none", разбор ошибок промисаplay()и «звук всегда включён» с фолбэком в mute — реализованы как описано (utils/media.ts). Упрощено: ролики каталога — 10-секундные, то есть по логике плана идут веткой progressive MP4; hls.js, лестница качеств,MediaCapabilitiesи blurhash-постеры не подключались — им просто нет применения на этом контенте. Вместо пула<video>элементы живут вместе со слайдами DOM-окна, а потолок декодеров держится явным освобождением (releaseMedia) — при переходе на HLS и длинные ролики пул станет нужен.
Нативный CSS scroll-snap. Кастомный жест на вертикали не делаю.
.feed { height: 100svh; overflow-y: auto; scroll-snap-type: y mandatory;
overscroll-behavior-y: contain; touch-action: pan-y; }
.slide { height: 100svh; scroll-snap-align: start;
scroll-snap-stop: always; contain: strict; }scroll-snap-stop: always — это и есть залипание: один свайп даёт ровно один переход. Без него флик проматывает 5–10 карточек и останавливается на случайной, ни одно видео не успевает стартовать. Размен понятный: быстро «пролететь» ленту в поиске конкретного ролика становится нельзя.
100svh, а не dvh. При скролле сворачивается адресная строка, dvh пересчитывается, высота слайдов меняется прямо во время движения и снап-точки уезжают. svh стабилен.
Нативный скролл вместо JS-жеста — потому что он идёт на композиторе и не зависит от занятости main thread, а main thread здесь постоянно занят декодированием и рендером. Плюс родная инерция каждой платформы, которую всё равно точно не повторить. Кастомный жест взял бы только ради эффекта, невозможного на нативном скролле.
Определение активного элемента — два разных сигнала:
- IntersectionObserver (threshold 0.6) даёт кандидата. По нему стартует подгрузка.
scrollendдаёт активного. По нему стартует воспроизведение.
Фолбэк для Safari до 18.2 — пассивный scroll-листенер с дебаунсом 120 мс, пишущий только в ref. Никаких вычислений на каждом событии скролла.
Это разделение и решает быстрый скролл: во время флика подгрузка идёт (она дешёвая и отменяемая), а play() не вызывается.
Жесты поверх — Pointer Events, один обработчик на контейнере с делегированием через closest('[data-video-id]'):
| Жест | Действие |
|---|---|
| тап | пауза/плей |
| драг снизу | перемотка, hit-area 44 px |
touch-action: pan-y оставляет вертикаль браузеру, драг по скрабберу забирает JS. Десктоп: стрелки, пробел, колесо работают через тот же снап.
В прототипе. Реализовано практически один в один: снап с
alwaysи100svh, оба сигнала (hooks/useIntersectionCandidate.ts,hooks/useScrollEnd.ts— в фолбэке scroll-событие только пишет метку времени, тишину проверяет rAF-цикл), делегированный тап на контейнере (Feed.tsx), скраббер через Pointer Events сsetPointerCapture(Slide.tsx), клавиатура (hooks/useKeyboardNav.ts). Дополнение сверх плана: индекс наscrollendсчитается отscrollTop, а не от последнего кандидата — это покрывает прыжок скроллбаром в область спейсера, где IntersectionObserver молчит.
Ролик целиком вперёд не грузится никогда. Соседям кладём префикс ~2 секунды, дальше поток догружается уже во время просмотра.
Окно асимметричное, −1 / +3: движение по ленте почти всегда вперёд, назад нужен быстрый откат ровно на шаг.
i-2 i-1 i i+1 i+2 i+3 i+4…
┌───────┐┌────────┐┌──────────┐┌───────────┐┌───────────┐┌────────┐┌──────┐
│RELEASE││ HOLD ││ ACTIVE ││ WARM ││ WARM ││ READY ││ META │
│декодер││буфер ││растущий ││ +декодер ││ без декод.││постер +││JSON +│
│отпущен││обрезан ││буфер ││ ~2 с, ││ ~2 с в ││preconn.││blur- │
│постер ││до ~2 с ││4 с → 15 с││ 1-й кадр ││ кэше SW ││ ││hash │
└───────┘└────────┘└──────────┘└───────────┘└───────────┘└────────┘└──────┘
0 КБ ~2 с по факту ~300 КБ ~300 КБ ~30 КБ ~1 КБ
досмотра
Ключевое здесь — байты отдельно от декодера. Префикс можно положить в Cache API вообще без <video>-элемента, привязка при переходе стоит 30–50 мс. Так байтов вперёд лежит на три-четыре ролика, а занятых декодеров всё равно три: активный, i+1 и i−1.
Почему префикс в секундах, а не байтами и не файлом целиком. Медианный досмотр короткого видео заметно меньше 100 %, и предзагруженный целиком 30-секундный ролик при выходе на второй секунде — это 28 секунд оплаченного впустую трафика. Двух секунд достаточно по построению: при сегментах по 2 с воспроизведение первого идёт параллельно с загрузкой второго, и пока канал шире битрейта, буфер не пустеет. Больше префикс старт не ускоряет, первый кадр уже есть. А фиксированный байтовый бюджет пришлось бы пересчитывать под каждую ступень качества: 400 КБ это 8 секунд 360p и 0.4 секунды 1080p.
Тот же принцип на активном элементе: буфер растущий. Пока не ясно, останется ли пользователь, держим 4 секунды; после ~3 секунд просмотра расширяем до 15.
// тир = конфигурация буфера, hls.config меняется на лету
function applyTier(hls: Hls, tier: Tier) {
switch (tier) {
case Tier.WARM: hls.config.maxBufferLength = 2;
hls.config.maxMaxBufferLength = 2;
hls.startLoad(0); break;
case Tier.ACTIVE: hls.config.maxBufferLength = 4;
hls.config.backBufferLength = 10; break;
case Tier.HOLD: hls.stopLoad(); break; // байты есть, сеть не трогаем
}
}
video.addEventListener('timeupdate', () => {
if (video.currentTime > 3 && hls.config.maxBufferLength < 15) {
hls.config.maxBufferLength = 15; // пользователь остался
}
}, { passive: true });Для progressive MP4 префикс считается арифметикой: Range: bytes=0-(moovSize + bitrate*2/8).
Через что качаем. Все медиа-запросы идут через Service Worker: Cache API с LRU и байтовым бюджетом (не больше 10 % от navigator.storage.estimate(), потолок ~200 МБ), очередь с максимум двумя параллельными префетчами, AbortController на каждом. Соблазн просто дёргать fetch с Range в расчёте на браузерный HTTP-кэш я бы не брал: попадание зависит от заголовков и Vary, а Safari часто ходит мимо кэша своим range-запросом. Отдельная мина — Safari требователен к корректной обработке 206 Partial Content внутри SW, иначе ломается перемотка; это первое, что я бы проверил на устройстве.
Адаптация. Размер окна и потолок качества считаются из navigator.connection и deviceMemory:
saveDataили 2G → окно +1, префикс только у i+1, потолок 360–480p- 3G → окно +2, потолок 480p
- 4G/Wi-Fi, deviceMemory ≥ 4 → окно +3, пул 5, потолок 1080p
Плюс реакции: visibilitychange в hidden — пауза всего и отмена префетчей (вкладка в фоне, продолжающая качать видео, это главный источник жалоб на трафик); заряд ниже 20 % — окно до 1; если droppedVideoFrames превысил ~5 % за последние 10 секунд, понижаем качество.
Порядок операций в планировщике. Сначала понижаем тиры у выпавших из окна и освобождаем декодеры, только потом повышаем у новых, по возрастанию расстояния от активного. Наоборот нельзя: упрёмся в потолок декодеров и получим тихий отказ.
Флик. Если за 100 мс проскроллили больше полутора экранов — режим пролистывания: все декодеры на паузу, показываем постеры, hls.stopLoad(), префетчи отменяем. Выход по scrollend.
Пагинация. Курсорная, страница 8–10, следующая запрашивается при activeIndex >= length - 4.
В прототипе. Ядро секции работает: асимметричное окно −1/+3 (
applyMediaWindowвutils/media.ts), явное освобождение декодера с сохранением позиции в LRU-карту (кап 300), пагинация страница 8 / порог 4, пауза и отмена активности наvisibilitychange(hooks/useVisibilityPause.ts). Тиры сведены к нативномуpreload: у соседейmetadata(префикс, не файл), у активногоauto— для 10-секундных MP4 этого достаточно. Упрощено: вместо Service Worker + Cache API — браузерный HTTP-кэш (на тестовом CDN он попадает, и байты при возврате в окно не перекачиваются; в проде так полагаться нельзя — см. про Safari иVaryвыше), нет адаптации подnavigator.connectionи отдельного режима флика — роль последнего частично закрывает разделение кандидат/активный из №2.
| Слой | Чем | Что лежит |
|---|---|---|
| Серверный кэш | TanStack Query, useInfiniteQuery |
страницы ленты, метаданные, курсоры |
| Глобальный UI | Zustand со селекторами | activeId, playbackRate, профиль сети |
| Медиа-рантайм | обычные объекты и refs, вне React | пул <video>, инстансы hls.js, тиры, буферы |
| Локально в слайде | useState / useRef |
показ контролов, раскрытое описание |
| Персистентно | IndexedDB + localStorage | снапшот последней страницы, позиции просмотра, seen-ids |
| Медиа-байты | Cache API через SW | сегменты и постеры, LRU |
Главное правило: currentTime в сторе не лежит. Это 30–60 обновлений в секунду, в глобальном сторе — перерисовка дерева, в локальном стейте — 60 ре-рендеров слайда. Прогресс пишем в DOM напрямую:
const step = () => {
bar.style.setProperty('--p', String(video.currentTime / video.duration));
video.requestVideoFrameCallback(step);
};
video.requestVideoFrameCallback(step);requestVideoFrameCallback срабатывает на реальных кадрах и сам засыпает на паузе. Туда же, мимо стора: уровень буфера, текущее качество, состояние сети. Наружу публикуются только редкие события — пошло, встало, ошибка.
Просмотренное разложено на три вещи, которые часто путают: seen-ids (Set, уходит на бэкенд для дедупликации следующей страницы, храним последние ~500), позиции просмотра (Map<id, seconds> — нужны для тира HOLD и для восстановления после перезагрузки), и собственно байты в Cache API.
Восстановление позиции. При уходе с ленты сохраняем { activeId, currentTime, feedSnapshot, scrollTop } в стор и sessionStorage. При возврате рендерим снапшот синхронно и скроллим в useLayoutEffect с behavior: 'instant', иначе будет видимый прыжок. Ревалидация фоном, новые элементы дописываем в конец. Уже показанное не переупорядочиваем: иначе под пальцем поменяется видео.
В прототипе. Расслоение выдержано точно по правилу: Zustand — только
activeIndexиpaused(lib/store.ts, 18 строк), медиа-рантайм в refs вне React (Feed.tsx), прогресс — напрямую в CSS-переменную черезrequestVideoFrameCallbackс rAF-фолбэком (hooks/useProgress.ts), при смене активного перерисовываются два слайда плюс контейнер. Восстановление позиции после перезагрузки не делалось — сознательно: ленты коротких видео после перезапуска начинают заново, «свежая лента» — это поведение продукта, а не упущение (позиции просмотра при этом живут в LRU-карте в памяти — откат назад в рамках открытой страницы продолжает с того же кадра). Упрощено: TanStack Query и IndexedDB не нужны — бэкенда нет, каталог локальный (lib/videos.ts), «курсорная» пагинация циклится по нему; seen-ids без сервера дедуплицировать негде.
Дорог здесь не DOM, а медиа: пустой слайд это 20–30 узлов, активный <video> — десятки мегабайт и занятый декодер. Поэтому окно в первую очередь по медиа (№3), и только во вторую по DOM.
Виртуализация. Все слайды одной высоты, поэтому ни замеров, ни кэша высот не нужно: в DOM держим окно, всё остальное схлопнуто в два спейсера.
<div className="feed">
<div style={{ height: `calc(${start} * 100svh)` }} aria-hidden />
{feed.slice(start, end).map(item => <Slide key={item.id} item={item} />)}
<div style={{ height: `calc(${feed.length - end} * 100svh)` }} aria-hidden />
</div>Тонкость: у спейсеров нет снап-точек, в них можно провалиться на быстром флике. Лечится расширением окна заранее (IntersectionObserver с rootMargin: '200%' на границах) и тем же scroll-snap-stop: always.
Библиотечная виртуализация (@tanstack/virtual) со scroll-snap дружит плохо: при абсолютном позиционировании снап-точки пересоздаются на лету, на iOS это даёт дрожание. Для MVP я бы вообще начал с content-visibility: auto + contain-intrinsic-size: 100svh — три строки CSS, нативный снап работает как есть, а окно из кода добавил бы по метрике размера DOM, если сессии реально уходят за полторы-две сотни роликов.
Ре-рендеры. Слайд не получает isActive пропом сверху — это заставит родителя перерисовать всех детей. Он подписывается сам:
const isActive = useFeedStore(s => s.activeId === item.id);При переходе перерисовываются ровно два слайда. Дальше по мелочи: React.memo с узкой поверхностью пропов, обработчики делегированы на контейнер и стабильны, Context только для стабильных зависимостей вроде сервисов (для activeId он не годится — селектора нет, перерисуются все потребители), подгрузка следующей страницы в startTransition. На React Compiler опираюсь как на страховку, но неправильную форму состояния он не чинит.
Проверка не на глаз: Profiler с «why did this render», смена активного элемента должна давать не больше трёх отрендеренных компонентов.
Композитинг. Анимируем только transform и opacity. will-change точечно и снимаем после анимации: каждый слой это GPU-память, десяток лишних на бюджетном телефоне даёт не ускорение, а падение вкладки. contain: strict на слайде. Оверлеи отдельным слоем над видео.
Память. Типовые протечки: <video> без removeAttribute('src') + load(), невызванный hls.destroy(), неотписанные обсерверы, замыкания на массив ленты в долгоживущих обработчиках. Проверяю сценарием «пролистать 200 роликов» под Memory Profiler плюс автотестом на document.querySelectorAll('video').length <= poolSize.
В прототипе. Виртуализация — ровно по схеме из кода выше: DOM-окно −3/+5 со спейсерами (
Feed.tsx), плюсcontent-visibility: autoна слайдах; DOM-окно намеренно шире медиа-окна, поэтому слайд, покидающий DOM, уже освобождён — декодер отпущен, позиция сохранена. Подписка слайда селектором,React.memo, стабильные колбэки (в том числе стабильныйref-колбэк видео, чтобы React не дёргал register/unregister на каждом рендере),startTransitionна пагинации — всё на месте. Слайды из DOM-окна при уходе размонтируются, но скролл назад до самого начала работает: позиции в LRU, байты в HTTP-кэше. Упрощено: прод-метрики (time-to-first-frame, waste ratio, dropped frames) и автотест на утечки не делались.
Явная пользовательская фильтрация. Самое заметное, чего нет ни у кого. Подбор непрозрачен, а надёжной ручки у пользователя нет: «не интересно» это чёрный ящик с неизвестной задержкой, проверить эффект невозможно. Люди заводят вторые аккаунты именно чтобы переобучить ленту, и это симптом отсутствующей фичи. Я бы дал детерминированные правила по метаданным — автор, звук, хэштег, ключевое слово, язык, длительность — применяемые на клиенте мгновенно и параллельно уходящие на бэкенд с курсором следующей страницы. Алгоритм отвечает за «что предложить», пользователь за «чего я точно не хочу». Отсюда естественно вырастают несколько именованных лент: сохранённые наборы правил как вкладки вместо одного непрозрачного потока.
Технически фильтр встаёт до планировщика предзагрузки, а не на уровне рендера — тогда отфильтрованное вообще не доходит до видео-трафика. Риск у фичи один и серьёзный: при агрессивных правилах выдача схлопывается, нужен предохранитель на пагинации и честное «фильтры скрыли 47 из 50» вместо пустого экрана. И это про вкус, а не про безопасность: фильтр судит по метаданным, модерация обязана быть серверной.
В прототипе не реализовано — это продуктовая идея для взрослой версии: каталог из 15 тестовых роликов фильтровать нечего.
Лента запускается в том же WebView, что и мобильный браузер (WKWebView на iOS, Chromium WebView на Android), поэтому вся оптимизация из №1–5 переносится без изменений — это и есть оптимизация под WebView. Отличия TMA — в обвязке:
disableVerticalSwipes()— обязательный первый вызов. Свайп вниз в Telegram сворачивает мини-апп, то есть жест ленты прямо конфликтует с жестом платформы. Без этого лента неюзабельна.ready()+expand()на старте: разворачиваемся на полную высоту до первого кадра, иначе слайды пересчитают размер прямо под пальцем.- Высота: вместо
100svh—var(--tg-viewport-stable-height, 100svh). Telegram отдаёт стабильную высоту своей CSS-переменной, фолбэк оставляет браузерную версию рабочей. themeParams→ CSS-переменные: тёмная/светлая тема из клиента Telegram, а не изprefers-color-scheme.- Вся обвязка за проверкой
window.Telegram?.WebApp— в браузере SDK просто нет, и то же самое приложение работает как обычный веб. Один код, две площадки.
В прототипе не реализовано: прототип — чистый веб. Секция актуальна при упаковке в Telegram; ядро (№1–5) при этом не меняется.
REST — курсорная лента. GET /feed?cursor=… → { items, nextCursor }, в запросе уходят seen-ids для дедупликации (№4). Поверх — TanStack Query с useInfiniteQuery: ретраи с backoff, кэш страниц. Ошибка загрузки страницы не роняет ленту: показываем уже загруженное плюс тост с ретраем.
WebSocket — только то, что реально realtime. Одно соединение на сессию, подписка по activeId: живые счётчики просмотров/реакций текущего ролика, отзыв ролика модерацией (убрать из ленты до показа), сигнал инвалидации курсора. Сменился активный слайд — resubscribe, а не N подписок на всё окно: пуш для роликов, которые пользователь ещё не смотрит, это тот же паразитный трафик, что и лишний префетч. Reconnect с экспоненциальным backoff и jitter, деградация в polling, при офлайне исходящее — в очередь IndexedDB (№4).
Потоковые данные. Видео и есть главный поток: HLS-сегменты через Service Worker с управлением буфером (№3). WebSocket к медиа не подключаю — доставка сегментов по HTTP с CDN-кэшированием на порядки дешевле и уже решает задачу.
В прототипе бэкенда нет:
lib/videos.tsгенерирует бесконечную ленту, циклясь по локальному каталогу. ИнтерфейсgetPage(offset)сознательно повторяет форму курсорной пагинации — замена на реальный API с TanStack Query не потребует трогатьFeed.
- Валидация
initData— только на сервере. Telegram подписывает данные пользователя HMAC-SHA256 от bot token; клиент шлётinitDataкак есть, бэкенд проверяет подпись и срок, и только после этого выдаёт короткоживущий сессионный токен. ДоверятьinitData.userбез проверки — типовая дыра TMA. - CSP:
script-src 'self',media-src— только CDN,connect-src— только API и WS. Инлайн-скриптов нет. - UGC-метаданные (описания, хэштеги, имена авторов) рендерятся только через React-экранирование,
dangerouslySetInnerHTMLзапрещён; внешние ссылки —rel="noopener noreferrer". - Секретов на клиенте нет; если появятся приватные ролики — подписанные URL с TTL вместо «секретных» путей.
- Модерация контента серверная (№6): клиент фильтрует по вкусу, но не решает, что допустимо показывать.
В прототипе соблюдено то, что применимо без бэкенда: метаданные рендерятся только через React-экранирование,
dangerouslySetInnerHTMLне используется, секретов на клиенте нет. CSP и валидацияinitData— прод и TMA соответственно.
В порядке приоритета — каждый шаг ложится в уже существующие швы:
- Реальный API — заменить
lib/videos.tsна TanStack Query с курсорной пагинацией (№8); формаgetPageуже совпадает. - HLS для длинных роликов — hls.js с тирами буфера (№3) внутри
attachMedia/releaseMedia, снаружи контракт медиа-окна не меняется. - Пул
<video>— как только появляются длинные ролики и Android-стенд (№1). - Service Worker + Cache API — управляемый кэш префиксов с байтовым бюджетом вместо надежды на HTTP-кэш (№3).
- Постеры с blurhash и адаптация под сеть (
navigator.connection) — №1 и №3. - Метрики — time-to-first-frame, ребуферизация, waste ratio, p75/p95 в разрезе сеть × устройство (№5).