Skip to content

Repository files navigation

simple-video-feed — прототип вертикальной видеоленты

Рабочий прототип ленты вертикального видео (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 dev

Структура

app/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.


№1. Стек и работа с медиа

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 и длинные ролики пул станет нужен.

№2. Скролл и залипание

Нативный 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 молчит.

№3. Предзагрузка

Ролик целиком вперёд не грузится никогда. Соседям кладём префикс ~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.

№4. Управление состоянием

Слой Чем Что лежит
Серверный кэш 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 без сервера дедуплицировать негде.

№5. Производительность

Дорог здесь не 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) и автотест на утечки не делались.

№6. Что бы я сделал иначе

Явная пользовательская фильтрация. Самое заметное, чего нет ни у кого. Подбор непрозрачен, а надёжной ручки у пользователя нет: «не интересно» это чёрный ящик с неизвестной задержкой, проверить эффект невозможно. Люди заводят вторые аккаунты именно чтобы переобучить ленту, и это симптом отсутствующей фичи. Я бы дал детерминированные правила по метаданным — автор, звук, хэштег, ключевое слово, язык, длительность — применяемые на клиенте мгновенно и параллельно уходящие на бэкенд с курсором следующей страницы. Алгоритм отвечает за «что предложить», пользователь за «чего я точно не хочу». Отсюда естественно вырастают несколько именованных лент: сохранённые наборы правил как вкладки вместо одного непрозрачного потока.

Технически фильтр встаёт до планировщика предзагрузки, а не на уровне рендера — тогда отфильтрованное вообще не доходит до видео-трафика. Риск у фичи один и серьёзный: при агрессивных правилах выдача схлопывается, нужен предохранитель на пагинации и честное «фильтры скрыли 47 из 50» вместо пустого экрана. И это про вкус, а не про безопасность: фильтр судит по метаданным, модерация обязана быть серверной.

В прототипе не реализовано — это продуктовая идея для взрослой версии: каталог из 15 тестовых роликов фильтровать нечего.

№7. Telegram Mini App

Лента запускается в том же 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) при этом не меняется.

№8. API, WebSocket и потоковые данные

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.

№9. Безопасность

  • Валидация 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 соответственно.


Первые шаги к продовской версии

В порядке приоритета — каждый шаг ложится в уже существующие швы:

  1. Реальный API — заменить lib/videos.ts на TanStack Query с курсорной пагинацией (№8); форма getPage уже совпадает.
  2. HLS для длинных роликов — hls.js с тирами буфера (№3) внутри attachMedia/releaseMedia, снаружи контракт медиа-окна не меняется.
  3. Пул <video> — как только появляются длинные ролики и Android-стенд (№1).
  4. Service Worker + Cache API — управляемый кэш префиксов с байтовым бюджетом вместо надежды на HTTP-кэш (№3).
  5. Постеры с blurhash и адаптация под сеть (navigator.connection) — №1 и №3.
  6. Метрики — time-to-first-frame, ребуферизация, waste ratio, p75/p95 в разрезе сеть × устройство (№5).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages