myYouTube/analytics/2026-09-28-shorts-audio-persistence.md
vrubelroman 252f38597d Split shorts from regular videos with a vertical shorts feed
- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
  and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
  'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
  autoplay for the active slide, context-aware endpoints and infinite
  loading.
- Keep the sound choice across swipes, syncing with the player's own
  mute control and guarding against the widget's stale isMuted() reads.
2026-09-28 09:33:35 +00:00

15 KiB
Raw Permalink Blame History

Багфикс: звук в ленте Shorts сбрасывается при листании

Задача

Устранить регрессию ленты Shorts /shorts/:youtubeVideoId: при листании к следующему ролику звук снова выключен, даже если пользователь включил его на текущем слайде. Требуется, чтобы выбор звука («вкл/выкл») сохранялся на все последующие слайды в рамках открытой ленты, не ломая autoplay.

Это отдельный багфикс поверх механики ленты из analytics/2026-09-27-shorts-split-and-feed.md (документ исторический, его решения не переписываются).

Контекст

Баг воспроизводится и подтверждён по фактическому коду (ветка master, правки shorts в незакоммиченном worktree):

  • frontend/src/components/ShortsPlayer.tsx:16 держит звук локально: const [muted, setMuted] = useState(true). Это состояние живёт ровно столько, сколько смонтирован конкретный плеер.
  • frontend/src/pages/ShortsFeed.tsx:162-163 монтирует <ShortsPlayer video={video} /> только для активного слайда, а неактивные показывает постером. При смене активного слайда старый плеер размонтируется, новый создаётся заново — и снова с muted = true.
  • Для YouTube это усилено на двух уровнях: playerVars: { ..., mute: 1 } при создании (ShortsPlayer.tsx:57) и player.mute?.() в onReady (ShortsPlayer.tsx:66).
  • Итог: состояние звука не переносится между роликами, потому что единственный его владелец — плеер одного слайда.

Это следствие осознанного решения «плеер монтируется только для активного слайда» из 2026-09-27-shorts-split-and-feed.md (гарантированная остановка звука/воспроизведения при свайпе без ручного pause). Решение остаётся в силе; меняется только владелец флага звука.

Потребителей у ShortsPlayer ровно один — ShortsFeed.tsx (подтверждено grep по frontend/src), поэтому изменение пропсов безопасно и локально.

Затронутые подсистемы и файлы

Frontend (единственная подсистема):

  • frontend/src/pages/ShortsFeed.tsx — новый источник правды для звука: const [muted, setMuted] = useState(true) на уровне ленты; передать muted и onToggleMute в ShortsPlayer.
  • frontend/src/components/ShortsPlayer.tsx — убрать локальный useState(muted); принимать muted: boolean и onToggleMute: () => void; применять состояние к обоим типам плеера; кнопка звука управляет общим состоянием.
  • frontend/src/utils/youtube.ts — изменений не требуется (YoutubePlayerApi уже содержит опциональные mute?/unMute?).
  • frontend/src/App.css — изменений не требуется (класс .shorts-mute и иконка не меняются).

Backend, миграции, env — не затрагиваются. Тестов на frontend в проекте нет; проверка — npm run lint и npm run build.

Проектное решение (без костылей)

Владелец состояния

Единый источник правды — ShortsFeed (живёт, пока открыта лента, включая подгрузку следующих страниц). ShortsPlayer становится полностью контролируемым презентационным компонентом: пропсы muted + onToggleMute, без собственного useState.

Почему не sessionStorage/localStorage и не глобальный store: это единичное UI-состояние одной страницы, лишняя инфраструктура не нужна; кроме того, персист между перезагрузками запрещён (см. ниже).

Локальное видео

<video ... muted={muted} /> — React выставляет DOM-свойство muted при каждом рендере, включение/выключение не требует императивных вызовов.

YouTube (IFrame API)

  • При создании: playerVars: { playsinline: 1, autoplay: 1, mute: muted ? 1 : 0 }, где muted берётся из пропса (значение на момент монтажа слайда).
  • Значение muted нельзя добавлять в зависимости эффекта создания плеера ([useLocal, video.youtube_video_id]): иначе каждый клик по кнопке будет пересоздавать YT.Player (перезагрузка ролика). Поэтому:
    • актуальное значение для onReady держим в рефе: const mutedRef = useRef(muted); mutedRef.current = muted; в onReady вызываем mutedRef.current ? player.mute?.() : player.unMute?.() (вместо безусловного player.mute?.());
    • отдельный эффект useEffect(() => { const p = ytRef.current; if (!p) return; if (muted) p.mute?.(); else p.unMute?.(); }, [muted]) — применяет изменение звука к уже созданному плееру. На маунте плеера может быть ещё null — безопасно; фактическое начальное состояние обеспечивают playerVars.mute и onReady.
  • После включения звука пользователем браузер уже получил жест, поэтому программный unMute() на следующих слайдах autoplay не ломает.

Кнопка звука и фолбэк на plain-iframe

  • Кнопка остаётся в ShortsPlayer, но вызывает onToggleMute (родительский сеттер); aria-label/иконка — от эффективного состояния.
  • Решение по фолбэку: если IFrame API не поднялся (ytFailed, ShortsPlayer.tsx:117-124), plain-iframe остаётся с mute=1, а кнопка — disabled, как сейчас. Причина: у plain-iframe нет JS-управления без перезагрузки src, а mute=0 ломает autoplay деградационного пути. Чтобы UI не «врал», в режиме фолбэка иконка/aria-label показывают «выключено»: const effectivelyMuted = (!useLocal && ytFailed) ? true : muted. Сессионное muted при этом не меняется и продолжает действовать на последующих слайдах с рабочим API.

Дефолт и персист

  • При входе в ленту звук выключен (useState(true)) — иначе браузеры блокируют программный автоплей без «свежего» жеста пользователя.
  • После явного включения состояние держится до конца жизни компонента ShortsFeed (включая уже подгруженные и ещё подгружаемые слайды). Уход со страницы/перезагрузка → снова выключено.
  • Персист между перезагрузками сознательно НЕ делаем (ни localStorage, ни sessionStorage, ни cookie). Причина: восстановленное «включено» применилось бы к автоплею без жеста пользователя, и браузер заблокировал бы воспроизведение на первом слайде — это худший сценарий, чем повторное включение звука одним тапом.

Критерии приёмки

  1. Включить звук на текущем слайде и пролистать вперёд/назад — звук остаётся включённым на следующем/предыдущем ролике; сброса в «выключено» нет.
  2. Работает для локального файла (<video muted={muted}> обновляется при листании) и для YouTube (новый плеер стартует с mute: 0, при необходимости звук применяется через mute()/unMute()).
  3. Autoplay не сломан: при входе в ленту звук выключен и активный ролик автоматически воспроизводится без жеста; переключение звука не приводит к перезагрузке/пересозданию плеера.
  4. Переключение звука на текущем слайде по-прежнему работает кнопкой; иконка и aria-label соответствуют фактическому состоянию (в фолбэке — «выключено»).
  5. Фолбэк на plain-iframe (ytFailed) не регрессирует: iframe автоплеит с mute=1, кнопка disabled, ошибок/белого экрана нет; состояние звука после возврата на слайд с рабочим API сохраняется.
  6. Гонка «переключил звук до готовности плеера» безопасна: onReady применяет актуальное значение через реф, а не захваченное на маунте.
  7. Состояние живёт в пределах открытой ленты и сбрасывается в «выключено» при уходе/перезагрузке; между перезагрузками ничего не персистится.
  8. npm run lint и npm run build в frontend/ — зелёные.
  9. Деплой по правилу команды: docker compose up -d --build, curl http://localhost:8080/api/health → OK.

План

  1. ShortsFeed.tsx: добавить const [muted, setMuted] = useState(true); прокинуть <ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((m) => !m)} />. Учесть, что при смене routeKey/контекста (сброс на первый слайд, :63-66) звук по решению сессии не сбрасывается — флаг живёт до размонтирования ShortsFeed.
  2. ShortsPlayer.tsx:
    • обновить Props: video, muted: boolean, onToggleMute: () => void;
    • удалить useState(muted) и локальный toggleMute; кнопку перевести на onToggleMute, иконку/aria-label — от effectivelyMuted;
    • YouTube: playerVars.mute от пропса, onReady через mutedRef, отдельный эффект на [muted] для mute()/unMute();
    • локальное видео: muted={muted}; фолбэк-iframe: mute=1, кнопка disabled.
  3. Ручная проверка в браузере: локальный ролик и YouTube по очереди — включить звук, пролистать 2-3 слайда (в т.ч. через клавиатуру), проверить отсутствие сброса; открыть ленту заново — звук выключен; сыграть сценарий с недоступным IFrame API (фолбэк).
  4. npm run lint и npm run build в frontend/.
  5. Деплой: docker compose up -d --build, health-check.

Риски и ограничения

  • Пересоздание плеера: главный риск фикса — добавить muted в зависимости эффекта создания YT.Player. Это недопустимо (каждый тоggl → перезагрузка ролика со сбросом позиции). Митигация: реф + отдельный эффект; критерий приёмки №3.
  • Autoplay-политика браузеров: причина дефолта muted=true и отказа от персиста. Программный unMute() на последующих слайдах допустим (жест уже был), но mute=0 при первом автоплее без жеста — нет.
  • Рассинхрон UI и фолбэка: plain-iframe всегда беззвучен, поэтому в этом режиме кнопка должна показывать «выключено»; иначе пользователь видит «звук включён», а слышит тишину.
  • Гонка до onReady: без рефа onReady применит устаревшее значение и сбросит только что включённый звук; критерий приёмки №6.
  • Сброс при навигации внутри ленты: смена routeKey (другой контекст/якорь) пересобирает список, но ShortsFeed не размонтируется — звук сохраняется. Это ожидаемо; сброс происходит только при уходе со страницы.
  • Тестовое покрытие: frontend-тестов нет, регрессия ловится только lint/build и ручной проверкой; автоматизировать e2e в этой задаче не планируется.

Журнал изменений

  • 2026-09-28: документ создан перед реализацией. Зафиксирован баг (локальный muted в ShortsPlayer.tsx:16 + ремоунт плеера на активный слайд в ShortsFeed.tsx:162-163 + mute: 1/player.mute() для YouTube) и фикс: поднять состояние звука в ShortsFeed, передавать muted/onToggleMute; для YouTube — playerVars.mute при создании и отдельный эффект mute()/unMute() (реф для onReady, чтобы не пересоздавать плеер); фолбэк plain-iframe остаётся mute=1 с disabled-кнопкой; дефолт «выключено»; персист между перезагрузками сознательно не делается из-за autoplay-политики. Потребитель ShortsPlayer единственный — ShortsFeed.