- 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.
15 KiB
Багфикс: звук в ленте 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). Причина: восстановленное «включено» применилось бы к автоплею без жеста пользователя, и браузер заблокировал бы воспроизведение на первом слайде — это худший сценарий, чем повторное включение звука одним тапом.
Критерии приёмки
- Включить звук на текущем слайде и пролистать вперёд/назад — звук остаётся включённым на следующем/предыдущем ролике; сброса в «выключено» нет.
- Работает для локального файла (
<video muted={muted}>обновляется при листании) и для YouTube (новый плеер стартует сmute: 0, при необходимости звук применяется черезmute()/unMute()). - Autoplay не сломан: при входе в ленту звук выключен и активный ролик автоматически воспроизводится без жеста; переключение звука не приводит к перезагрузке/пересозданию плеера.
- Переключение звука на текущем слайде по-прежнему работает кнопкой; иконка и
aria-labelсоответствуют фактическому состоянию (в фолбэке — «выключено»). - Фолбэк на plain-iframe (
ytFailed) не регрессирует: iframe автоплеит сmute=1, кнопкаdisabled, ошибок/белого экрана нет; состояние звука после возврата на слайд с рабочим API сохраняется. - Гонка «переключил звук до готовности плеера» безопасна:
onReadyприменяет актуальное значение через реф, а не захваченное на маунте. - Состояние живёт в пределах открытой ленты и сбрасывается в «выключено» при уходе/перезагрузке; между перезагрузками ничего не персистится.
npm run lintиnpm run buildвfrontend/— зелёные.- Деплой по правилу команды:
docker compose up -d --build,curl http://localhost:8080/api/health→ OK.
План
ShortsFeed.tsx: добавитьconst [muted, setMuted] = useState(true); прокинуть<ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((m) => !m)} />. Учесть, что при сменеrouteKey/контекста (сброс на первый слайд,:63-66) звук по решению сессии не сбрасывается — флаг живёт до размонтированияShortsFeed.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.
- обновить
- Ручная проверка в браузере: локальный ролик и YouTube по очереди — включить звук, пролистать 2-3 слайда (в т.ч. через клавиатуру), проверить отсутствие сброса; открыть ленту заново — звук выключен; сыграть сценарий с недоступным IFrame API (фолбэк).
npm run lintиnpm run buildвfrontend/.- Деплой:
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.