# Багфикс: звук в ленте 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` монтирует `` **только для активного слайда**, а неактивные показывает постером. При смене активного слайда старый плеер размонтируется, новый создаётся заново — и снова с `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-состояние одной страницы, лишняя инфраструктура не нужна; кроме того, персист между перезагрузками запрещён (см. ниже).
### Локальное видео
`` — 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. Работает для локального файла (`