Стриминговые приложения почти всегда состоят не из одного экрана: рядом с видео живут чат, заметки, расшифровка, список похожих роликов и другие панели. Если при переключении между ними состояние интерфейса не сохраняется, навигация быстро начинает раздражать.

Представьте: пользователь скрывает видеоплеер, а когда открывает его снова, воспроизведение начинается с самого начала. Именно такие мелочи заставляют думать, что посмотреть видео где-нибудь в другом месте было бы проще.

Если приложение написано на React, причина обычно в том, что при скрытии компонента React размонтирует его — а вместе с ним уничтожает и всё внутреннее состояние.

В этой статье разберём три сценария и шаг за шагом придём к решению этой проблемы с помощью нового компонента Activity из React 19.2 и Mux Player. По пути посмотрим на подводные камни и в конце получим самый аккуратный вариант из тех, что React сейчас предлагает «из коробки».

React раньше

До React 19.2 показ и скрытие компонентов чаще всего делали через условный рендеринг:

{isVideoTab ? <VideoPlayer /> : null}

Когда компонент видеоплеера скрывается, он размонтируется. Когда пользователь возвращается назад, React монтирует новый экземпляр, и видео снова запускается с начала. Весь прогресс воспроизведения теряется.

Для стриминговых приложений это откровенно плохой UX. Это всё равно что поставить на паузу свежую серию любимого сериала, свернуть плеер, а потом вернуться и обнаружить, что серия снова идёт с первой секунды.

Покажи, что умеет <Activity>

Компонент Activity предлагает новый способ управлять видимостью в React. Вместо того чтобы монтировать и размонтировать компоненты, он оставляет их в DOM и переключает только их состояние видимости.

У компонента есть проп mode с двумя значениями:

  • visible — компонент виден и с ним можно взаимодействовать
  • hidden — компонент остаётся смонтированным, но становится невидимым

Базовый пример выглядит так:

<Activity mode={activeTab === "video" ? "visible" : "hidden"}>
    <VideoPlayer />
</Activity>

В примерах ниже переключение происходит через вкладки и кнопки. Главное здесь в том, что Activity не даёт видеоплееру размонтироваться при смене вкладок. А значит, сохраняются:

  • текущая позиция воспроизведения
  • уже загруженный буфер
  • громкость
  • и всё остальное внутреннее состояние плеера

Три сценария: от проблемы к решению

Теперь — к практике. Ниже три реализации, каждая из которых исправляет недостатки предыдущей.

Сценарий 1. Классический условный рендеринг

Это самый прямолинейный вариант: обычный conditional rendering.

{isVideoTab ? (
    <div>
        <Player autoPlay muted />
    </div>
) : (
    <div>
        <NotesPanel />
    </div>
)}

Когда пользователь переключается с вкладки видео на заметки, React полностью размонтирует компонент <Player>. Когда он возвращается назад, создаётся новый экземпляр плеера, и воспроизведение начинается заново.

Итог простой: включил видео, ушёл на вкладку с заметками, вернулся — и снова видишь 0:00. Весь прогресс потерян.

С технической точки зрения здесь всё предсказуемо: нет обёртки Activity, значит, React уничтожает компонент при каждом переключении и каждый раз создаёт его заново. В стандартном жизненном цикле React нет встроенного механизма «заморозить» компонент, а потом вернуть его с прежним состоянием.

Сценарий 2. Activity, но без паузы

Здесь уже лучше: мы используем Activity, но этого всё ещё недостаточно.

<Activity mode={isVideoTab ? "visible" : "hidden"}>
    <div>
        <Player autoPlay muted />
    </div>
</Activity>
 
<Activity mode={!isVideoTab ? "visible" : "hidden"}>
    <div>
        <NotesPanel />
    </div>
</Activity>

Теперь плеер не размонтируется, когда становится невидимым, поэтому позиция воспроизведения сохраняется. Но появляется другой нюанс: видео продолжает играть в фоне. Пользователь открывает вкладку заметок, а звук из скрытого видео всё ещё идёт.

Это не обязательно ошибка — для некоторых интерфейсов такое поведение может быть уместным. Но чаще хочется другого: сохранить состояние и при этом автоматически ставить видео на паузу.

Важно понимать, что Activity скрывает содержимое через display: none, но не останавливает JavaScript и не управляет воспроизведением медиа. Видео остаётся в DOM, поэтому при возврате пользователь попадает ровно туда, где остановился. Просто никто не сказал плееру, что при скрытии надо поставить воспроизведение на паузу.

Сценарий 3. Activity с автопаузой — полноценное решение

Чтобы довести решение до ума, нужно объединить Activity с хуком useLayoutEffect, который будет ставить плеер на паузу в момент скрытия.

const PausingPlayerPanel = ({ isVisible, idPrefix }: PausingPlayerPanelProps) => {
    const playerRef = useRef<MuxPlayerElement | null>(null);
 
    useLayoutEffect(() => {
        const player = playerRef.current;
        return () => {
            player?.pause();
        };
    }, [isVisible]);
 
    return (
        <div hidden={!isVisible}>
            <Player autoPlay muted ref={playerRef} />
        </div>
    );
};

Так это оборачивается в Activity:

<Activity mode={isVideoTab ? "visible" : "hidden"}>
    <PausingPlayerPanel isVisible={isVideoTab} idPrefix="finished" />
</Activity>

Когда isVisible меняется с true на false, срабатывает cleanup-функция эффекта и ставит видео на паузу ещё до того, как пользователь увидит переключение вкладки. При этом Activity не даёт компоненту размонтироваться, поэтому позиция паузы сохраняется. Когда пользователь возвращается, он попадает ровно в ту точку, где остановился.

То есть поведение становится именно таким, каким его обычно ожидают:

  • переключился на заметки — видео сразу и беззвучно остановилось
  • вернулся назад — продолжил с того же места

Здесь критично использовать именно useLayoutEffect, а не useEffect. useLayoutEffect выполняется синхронно до перерисовки браузером, поэтому пауза успевает сработать до того, как пользователь заметит переход. С useEffect был бы шанс на короткий «хвост» звука из уже скрытой вкладки.

В результате получается лучшее из двух миров:

  • сохранение состояния благодаря Activity
  • предсказуемое поведение воспроизведения благодаря эффекту

Не забудьте про refs

Компонент <MuxPlayer> в примере — это тонкая обёртка над @mux/mux-player-react, которая пробрасывает ref дальше вниз и задаёт несколько значений по умолчанию:

const Player = forwardRef<MuxPlayerElement, PlayerProps>(
    ({ playbackId = DEFAULT_PLAYBACK_ID, streamType = "on-demand", ...rest }, ref) => (
        <MuxPlayer ref={ref} playbackId={playbackId} {...rest} />
    )
);

Проброс ref здесь принципиален. Благодаря ему родительский компонент получает доступ к реальному видеоэлементу и может напрямую вызывать методы вроде pause(). Без этого useLayoutEffect просто не смог бы управлять плеером.

Коротко повторим

Во всех трёх примерах используется один и тот же компонент <Player>, но по-разному устроено управление состоянием:

  1. UnmountedExample — условный рендеринг без Activity
  2. HiddenButPlayingExample — только обёртка Activity
  3. FinishedExampleActivity + useLayoutEffect с логикой паузы

Паттерн с Activity элегантно решает задачу сохранения состояния и при этом остаётся читаемым. И полезен он не только для видеоплееров. Точно так же его можно применять, например, в таких сценариях:

  • формы с несохранёнными данными — чтобы не терять введённый текст при переключении вкладок
  • таблицы с фильтрами — чтобы сохранять сортировку, фильтры и позицию скролла
  • рисовалки и canvas-интерфейсы — чтобы не терять сложное внутреннее состояние
  • музыкальные плееры — чтобы управлять воспроизведением независимо от навигации по приложению

Чеклист для старта

Если хочешь применить этот подход в своём видеоприложении, вот что понадобится:

  1. Обновиться до React 19.2, чтобы получить доступ к Activity
  2. Обернуть видеоплеер в Activity и переключать mode в зависимости от активной вкладки
  3. Пробросить ref из компонента плеера, чтобы родитель мог управлять видеоэлементом
  4. Добавить useLayoutEffect с логикой паузы, срабатывающей при изменении видимости

Итог

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

Если тебе нужно просто скрывать и показывать части интерфейса без потери состояния, Activity уже даёт очень многое. А если добавить к нему небольшой слой логики через useLayoutEffect, можно получить почти идеальное поведение для видеоплеера: без фонового воспроизведения и без потери позиции.

Ресурсы


Теги: react, streaming, video

Источник