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.
This commit is contained in:
parent
43adec5224
commit
252f38597d
25 changed files with 1297 additions and 88 deletions
99
analytics/2026-09-28-shorts-audio-persistence.md
Normal file
99
analytics/2026-09-28-shorts-audio-persistence.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Багфикс: звук в ленте 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue