myYouTube/analytics/2026-09-28-shorts-audio-persistence.md

100 lines
15 KiB
Markdown
Raw Permalink Normal View 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`.