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
175
analytics/2026-09-27-shorts-split-and-feed.md
Normal file
175
analytics/2026-09-27-shorts-split-and-feed.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Разделение видео на shorts/обычные и вертикальная лента Shorts
|
||||
|
||||
## Задача
|
||||
|
||||
Разделить видео на **shorts** и **обычные** по длительности и дать пользователю:
|
||||
1. Сегмент-фильтр «Все | Обычные | Shorts» во всех списках видео, с состоянием в URL (`?type=all|long|short`).
|
||||
2. Разные точки перехода по клику: short → вертикальная лента `/shorts/:youtubeVideoId`, обычное → существующая страница `/video/:youtubeVideoId`.
|
||||
3. Вертикальную full-screen ленту Shorts (YouTube/TikTok-подобную) с автоплеем активного ролика, паузой неактивных и бесконечной подгрузкой — в контексте того фильтра, откуда её открыли.
|
||||
|
||||
Порог определяется новым конфигом `SHORTS_MAX_DURATION_SECONDS` (по умолчанию `180`). Видео с неизвестной длительностью (`duration_seconds IS NULL`) считаются **обычными**. Миграции не нужны: `duration_seconds` уже хранится, shorts попадают тем же путём — через uploads-плейлист канала.
|
||||
|
||||
## Контекст
|
||||
|
||||
Проверено по фактическому коду (worktree чист, ветка `master`):
|
||||
|
||||
- Длительность уже сохраняется: `sync.py:221` парсит `duration_iso8601` → `Video.duration_seconds` (`backend/app/models/video.py:19`, nullable), а `youtube_client.py:242` берёт `contentDetails.duration` из videos.list по id из **uploads-плейлиста** (`sync.py:190-197`). Отдельный sync для shorts не нужен.
|
||||
- Фильтрация ленты — `GET /api/feed` (`backend/app/api/feed.py:77-151`): параметры `category_id/uncategorized/channel_id/downloaded/search/new_only/limit/cursor`, курсор `(published_at, id)` (`_encode_cursor`/`_decode_cursor`), сериализация через `serialize_video`. Фильтры применяются к одному `query` до курсора, поэтому новый фильтр по длительности естественно пагинируется вместе с остальными.
|
||||
- `serialize_video` (`backend/app/services/video_presentation.py:38-57`) — единая точка сериализации DTO видео для ленты, `GET /api/videos/{id}` (`videos.py:48-58`) и видео внутри `GET /api/channels/activity` (`channels.py:226-229`). Значит `is_short` достаточно добавить здесь один раз.
|
||||
- `GET /api/channels/activity` (`channels.py:130-233`) отдаёт в `videos` тот же DTO. По решению пользователя фильтр `type` в режиме «Каналы» (`?view=channels`) **не применяется** и на эндпоинт не передаётся.
|
||||
- `frontend/src/pages/Feed.tsx` обслуживает `/`, `/category/:id`, `/uncategorized`, `/local`, `/search`; режим `?view=channels` уже читается из `searchParams` (`Feed.tsx:22`). `supportsViewToggle` = `!isLocal && pathname !== '/search'` (`Feed.tsx:21`) — на `/local` и `/search` переключателя «Лента|Каналы» нет, но фильтр типа там нужен.
|
||||
- `frontend/src/pages/ChannelVideos.tsx` — отдельная страница списка видео канала; сейчас без query-фильтров и без view-toggle. Фильтр `type` добавляется и сюда.
|
||||
- Ссылки на видео строятся в `VideoCard.tsx:13` и `CompactVideoCard.tsx:11` (`/video/{youtube_video_id}`, `state={{ from }}`, сохранение скролла `feed-scroll:*`). Они — единственные места видео-ссылок (VideoPage/ProductPage используют их; `ChannelActivityList` рендерит `CompactVideoCard`).
|
||||
- `frontend/src/components/AppShell.tsx:69` (`Sidebar`) сохраняет `?view=channels` в ссылках «Все видео» и категорий — по этой же схеме нужно сохранять `?type`.
|
||||
- `frontend/src/api/client.ts:173-194` — `FeedVideoDto`; `getFeed` (`226-249`) кодирует query-параметры.
|
||||
- Роуты — `App.tsx:22-35`; `/video/:youtubeVideoId` есть, `/shorts/:youtubeVideoId` нет.
|
||||
- В README «Что осталось» shorts-фильтр указан как отложенный (`README.md:20`) — пункт нужно убрать после реализации.
|
||||
|
||||
Существующие тесты, которые **затронет** добавление поля: `tests/test_channel_activity.py:243-254` (`test_activity_item_shape`) проверяет точный набор ключей DTO видео — его нужно дополнить `is_short`. `tests/test_feed.py:303-317` (`test_feed_item_shape`) набор ключей видео целиком не фиксирует — безопасно.
|
||||
|
||||
## Затронутые подсистемы и файлы
|
||||
|
||||
Backend:
|
||||
- `backend/app/config.py` — новый `shorts_max_duration_seconds: int = 180` (рядом с `new_videos_window_days`).
|
||||
- `backend/app/api/feed.py` — новый query-параметр `type` (`all|long|short`, default `all`) и опциональный `anchor`; фильтр по `Video.duration_seconds`.
|
||||
- `backend/app/services/video_presentation.py` — `is_short` в DTO `serialize_video`.
|
||||
- `backend/app/api/channels.py` — без изменений логики фильтра (в `videos` появится `is_short` автоматически через `serialize_video`).
|
||||
- `.env.example` и `.env` — `SHORTS_MAX_DURATION_SECONDS=180` с комментарием.
|
||||
- Миграции — не нужны.
|
||||
|
||||
Frontend:
|
||||
- `frontend/src/api/client.ts` — `FeedVideoDto.is_short`, параметры `type`/`anchor` в `getFeed`, экспорт типа `VideoType`.
|
||||
- `frontend/src/pages/Feed.tsx` — чтение `type`, сегмент-контрол, проброс в `getFeed`, сохранение `type` в ссылках mobile-category-nav/local-filter-nav/view-toggle, учёт в queryKey.
|
||||
- `frontend/src/pages/ChannelVideos.tsx` — `type` из URL, сегмент-контрол, проброс в `getFeed`, сохранение в queryKey.
|
||||
- `frontend/src/components/AppShell.tsx` — `Sidebar` сохраняет `type` (и `view`) в ссылках «Все видео» и категорий.
|
||||
- `frontend/src/components/VideoCard.tsx`, `CompactVideoCard.tsx` — href по `is_short` + контекст ленты для `/shorts/:id`.
|
||||
- `frontend/src/App.tsx` — роут `/shorts/:youtubeVideoId`.
|
||||
- `frontend/src/pages/ShortsFeed.tsx` — **новый**: вертикальная лента; `frontend/src/components/ShortsPlayer.tsx` — **новый** (императивный плеер активного слайда, если выносить отдельно).
|
||||
- `frontend/src/App.css` — сегмент-контрол типа, лента shorts (scroll-snap), оверлеи, адаптив/`prefers-reduced-motion`.
|
||||
|
||||
Тесты:
|
||||
- `tests/test_feed.py` (или новый `tests/test_shorts_feed.py`) — фильтр `type`, границы, NULL, `is_short`, пагинация в отфильтрованном наборе, `anchor`; обновить `tests/test_channel_activity.py::test_activity_item_shape`.
|
||||
|
||||
Документация:
|
||||
- `README.md` — пункт в «Что сделано», чистка отложенного списка.
|
||||
|
||||
## API-контракт
|
||||
|
||||
`GET /api/feed` (добавление **обратносовместимое**, дефолт `all` = текущее поведение):
|
||||
|
||||
| Параметр | Тип | Дефолт | Поведение |
|
||||
|---|---|---|---|
|
||||
| `type` | `all \| long \| short` | `all` | `all` — без фильтра; `short` — `duration_seconds IS NOT NULL AND duration_seconds <= shorts_max_duration_seconds`; `long` — `duration_seconds IS NULL OR duration_seconds > shorts_max_duration_seconds`. Недопустимое значение → `422` (валидация через `Literal`). |
|
||||
| `anchor` | `str` (youtube_video_id) | нет | Если задан и видео найдено — страница начинается с этого видео и продолжается в сторону более старых (`published_at < a.published_at OR (== AND id <= a.id)`) поверх остальных фильтров; используется лентой Shorts для открытия конкретного ролика. Неизвестный якорь — деградация к обычной первой странице (без ошибки). |
|
||||
|
||||
Имя параметра в Python-сигнатуре — `video_type: Literal[...] = Query("all", alias="type")` (alias, чтобы не затенять builtin `type`); наружу это `?type=`.
|
||||
|
||||
Фильтр по типу:
|
||||
```python
|
||||
if video_type == "short":
|
||||
query = query.filter(
|
||||
Video.duration_seconds.is_not(None),
|
||||
Video.duration_seconds <= settings.shorts_max_duration_seconds,
|
||||
)
|
||||
elif video_type == "long":
|
||||
query = query.filter(
|
||||
or_(
|
||||
Video.duration_seconds.is_(None),
|
||||
Video.duration_seconds > settings.shorts_max_duration_seconds,
|
||||
)
|
||||
)
|
||||
```
|
||||
NULL-длительность не проходит `<=`/`>` автоматически, поэтому `long` обязан явно включать `IS NULL`. Курсор применяется после фильтров, как сейчас.
|
||||
|
||||
Поле DTO: во все сериализованные видео добавляется
|
||||
```
|
||||
"is_short": video.duration_seconds is not None and video.duration_seconds <= settings.shorts_max_duration_seconds
|
||||
```
|
||||
в `serialize_video` → автоматически во **feed items**, `GET /api/videos/{id}` и `videos` внутри `GET /api/channels/activity`. Порог читается из `settings` в момент вызова (тестируемо через `monkeypatch.setattr(settings, ...)`).
|
||||
|
||||
`GET /api/channels/activity` параметр `type` **не принимает** (режим «Каналы» не фильтруется), но возвращаемые видео получают `is_short`.
|
||||
|
||||
## План
|
||||
|
||||
### Backend
|
||||
1. `config.py`: `shorts_max_duration_seconds: int = 180`. В `.env.example` — секция/строка с комментарием: «Видео короче или равны этому числу (сек) считаются Shorts; неизвестная длительность — обычное видео». В `.env` — то же значение по образцу.
|
||||
2. `video_presentation.py`: `is_short` в `serialize_video` (импорт `settings`).
|
||||
3. `feed.py`: параметры `video_type`/`anchor`; фильтр длительности; разбор якоря (`Video.youtube_video_id == anchor`), условие старта страницы; порядок — фильтры → якорь → курсор → `order_by` → limit+1.
|
||||
4. Тесты: фикстура `client` как в `tests/test_feed.py`; сиды с явными `duration_seconds` (NULL, 179, 180, 181) и выполнением условий.
|
||||
5. Обновить `tests/test_channel_activity.py::test_activity_item_shape` (добавить `is_short`); в `tests/test_videos.py` добавить проверку `is_short` (pytest).
|
||||
|
||||
### Frontend — фильтр и ссылки
|
||||
6. `client.ts`: `is_short: boolean` в `FeedVideoDto`; `VideoType = 'all' | 'long' | 'short'`; в `getFeed` — `type` (шлём `type=all` явно или опускаем, единообразно) и `anchor`.
|
||||
7. `Feed.tsx`:
|
||||
- `type` = `searchParams.get('type')` с валидацией (default `all`), `typeQuery`.
|
||||
- Сегмент-контрол «Все | Обычные | Shorts» в `page-heading` рядом с view-toggle; рендер при `!isChannelsView` (то есть на `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`); в режиме «Каналы» фильтр не показывается.
|
||||
- `getFeed({ ..., type })`, `type` в queryKey.
|
||||
- Сохранять `type` в ссылках: mobile-category-nav, local-filter-nav, view-toggle (переход «Лента» из «Каналов» — с текущим `type`, если он есть), «Показать все» (`new=1`) — тоже с `type`.
|
||||
- Пустое состояние для `type=short`/`long` — отдельные тексты (например, «Здесь пока нет Shorts»).
|
||||
8. `ChannelVideos.tsx`: `type` из URL, сегмент-контрол, `getFeed({ channelId, type, ... })`, queryKey с `type`.
|
||||
9. `AppShell.tsx` (`Sidebar`): обобщить `viewQuery` в набор сохраняемых query-параметров `view`/`type`; переносить их в ссылки `/` и `/category/:id`. `?new=1` (badge) — feed-специфичен, оставить как сейчас.
|
||||
10. `VideoCard.tsx` / `CompactVideoCard.tsx`: `href = video.is_short ? shortHref(video, location) : '/video/'+id`; для short добавлять контекст текущего списка в query строку ленты.
|
||||
|
||||
Соглашение о контексте Shorts (параметры самой ссылки `/shorts/:id`):
|
||||
| Источник | Контекст в URL `/shorts/:id` |
|
||||
|---|---|
|
||||
| `/` | без параметров |
|
||||
| `/category/:id` | `category=<id>` |
|
||||
| `/uncategorized` | `uncategorized=true` |
|
||||
| `/local` (+ его `category`/`uncategorized`) | `downloaded=true[&category=..][&uncategorized=true]` |
|
||||
| `/search?q=..` | `q=<поисковый запрос>` |
|
||||
| `/channels/:id/videos` | `channel=<id>` |
|
||||
|
||||
`ShortsFeed` разбирает эти параметры и вызывает `getFeed({ type: 'short', categoryId, uncategorized, downloaded, search, channelId, anchor, cursor, limit })`. Контекст хранится в URL → refresh/шаринг сохраняют фильтр.
|
||||
|
||||
### Frontend — лента Shorts
|
||||
11. `App.tsx`: `<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />`.
|
||||
12. `ShortsFeed.tsx`: `useInfiniteQuery` по тем же context-параметрам с `type='short'` и `anchor=youtubeVideoId`; вертикальный скролл-контейнер; активный слайд; бесконечная подгрузка; empty/error/loading; кнопка «Назад» (как в VideoPage: `state.from` → `navigate(-1)` с fallback).
|
||||
13. Механика активного слайда (см. ниже).
|
||||
14. `App.css`: контейнер, слайды, оверлей, адаптив, reduced-motion.
|
||||
|
||||
### Проверка
|
||||
15. `pytest` (новые + регресс), `npm run lint`, `npm run build` в `frontend/`.
|
||||
16. Ручная проверка в браузере: фильтр на всех 6 местах, сохранение в URL при переключении категории/refresh; short → лента, обычное → страница; автоплей/пауза; подгрузка; режим «Каналы» не затронут.
|
||||
17. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## Решение по механике ленты Shorts (без костылей)
|
||||
|
||||
- **Разметка**: `.shorts-feed` — вертикальный скролл-контейнер (`overflow-y: auto; height: calc(100dvh - <topbar>); scroll-snap-type: y mandatory; overscroll-behavior: contain`); каждый `.shorts-slide` — `height: 100%; scroll-snap-align: start; scroll-snap-stop: always`. Один слайд за жест, без ручного расчёта позиций.
|
||||
- **Определение активного слайда**: один `IntersectionObserver` на контейнер, `threshold: 0.6`, наблюдает все слайды; слайд с `intersectionRatio >= 0.6` становится активным (`setActiveIndex`). Это чище, чем ручной `onScroll` + деление `scrollTop` на высоту: устойчиво к динамической высоте (мобильные панели, `100dvh`) и к разной вёрстке слайдов.
|
||||
- **Играет только активный**: плеер монтируется **только для активного слайда** (и, опционально, предзагружается соседний). Неактивные слайды содержат лишь постер-миниатюру. Анмаунт плеера = гарантированная остановка звука/воспроизведения, не нужен ручной `pause` для каждого типа плеера:
|
||||
- локальная копия — `<video autoPlay playsInline controls src={media_url}>`;
|
||||
- YouTube — `YT.Player` (уже есть загрузчик IFrame API в `Player.tsx:40-60`, переиспользовать) с `playerVars { autoplay: 1, playsinline: 1 }`; при размонтировании — `destroy()`.
|
||||
- Компромисс: ремоунт плеера при свайпе даёт короткую перезагрузку; для single-user приемлемо и предсказуемо. Альтернатива «держать все плееры и вызывать `pauseVideo`» дороже и даёт десятки одновременных iframe.
|
||||
- **Бесконечная подгрузка**: `IntersectionObserver`-sentinel в конце списка (или условие `activeIndex >= items.length - 3`) → `fetchNextPage()` при `hasNextPage`. Курсор — штатный `next_cursor` из `/api/feed`.
|
||||
- **Клавиатура/доступность**: контейнер `tabIndex={0}`, `ArrowDown/ArrowUp` → `scrollIntoView` соседнего слайда; на слайде видимые заголовок, канал, кнопка «Назад», кнопка mute/play (autoplay-политика), ссылка «Открыть на YouTube»; `aria-label` на слайде; `prefers-reduced-motion: reduce` отключает `scroll-behavior`/снап-анимации.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Сегмент-контрол «Все | Обычные | Shorts» показан на `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`, `/channels/:id/videos`; в режиме `?view=channels` он **не показывается** и фильтр не применяется (эндпоинт активности `type` не получает).
|
||||
2. `type` хранится в URL (`?type=all|long|short`); при переключении категории (сайдбар, mobile-nav), локальных фильтров, обновлении страницы (F5), back/forward фильтр сохраняется. Значение по умолчанию `all` может не выводиться в URL, поведение идентично.
|
||||
3. `type=short` возвращает только видео с `duration_seconds <= SHORTS_MAX_DURATION_SECONDS`; `type=long` — `> порога` **и** `NULL`; `type=all` — всё. Граница ровно на пороге (180) входит в shorts; 181 — в long; `NULL` — в long. Недопустимый `type` → `422`.
|
||||
4. Порог настраивается через `SHORTS_MAX_DURATION_SECONDS` (в `.env`/`.env.example`), изменение конфига сдвигает границу без правок кода.
|
||||
5. `is_short` присутствует в DTO ленты, `GET /api/videos/{id}` и в `videos` `GET /api/channels/activity`; для NULL/длинных — `false`.
|
||||
6. Пагинация в отфильтрованном наборе корректна: страницы `type=short` (и `long`) не пересекаются и не теряют элементы, `next_cursor` = `null` в конце. `anchor` заставляет первую страницу начинаться с указанного видео и продолжаться в сторону старых; неизвестный якорь не ломает запрос.
|
||||
7. Клик по short-карточке (`VideoCard`, `CompactVideoCard`, страница канала, лента) ведёт на `/shorts/:youtubeVideoId`; по обычной — на `/video/:youtubeVideoId` как раньше (с сохранением скролла `state.from`).
|
||||
8. Лента `/shorts/:id`: открывается на указанном видео; вертикальный свайп/скролл листает по одному; активный слайд воспроизводится (локальный `<video>` / YouTube iframe), неактивные не играют; при достижении конца подгружаются следующие shorts в **том же** контексте (категория/поиск/скачанное/канал); контекст берётся из URL `shorts`-ссылки и переживает refresh.
|
||||
9. Пустые состояния: нет shorts в контексте — понятный empty-state с возвратом; ошибка — «Повторить»; загрузка — скелетон слайда. Лента доступна с клавиатуры, слайды подписаны, есть кнопка «Назад».
|
||||
10. `pytest` (включая новые тесты `type`/границ/NULL/`is_short`/пагинации/`anchor` и обновлённый shape-тест), `npm run lint`, `npm run build` — зелёные; деплой `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- **Autoplay-политика браузеров**: программный `playVideo()` для YouTube на свайпе без «свежего» жеста может быть заблокирован. Митигация: старт с mute + видимая кнопка включения звука; локальный `<video>` автоиграет после перехода по клику. Это ограничение платформы, а не баг.
|
||||
- **Производительность**: держим в DOM плеер только активного слайда; постеры неактивных — `loading="lazy"`. Иначе десятки iframe/видео.
|
||||
- **`anchor` усложняет `/api/feed`**: добавлен только ради открытия конкретного short в ленте; покрыть тестами (старт с якоря, неизвестный якорь). Курсор остаётся штатным.
|
||||
- **`type` vs builtin**: в Python-сигнатуре используем alias; не путать query-имя `type` с внутренним `video_type`.
|
||||
- **NULL и БД**: `duration_seconds > x` сам исключает NULL — ветку `long` нельзя писать одним сравнением; тест на NULL обязателен (SQLite в тестах ведёт себя так же, как Postgres для этого случая).
|
||||
- **Рост `Feed.tsx`**: логика сохранения `view`/`type`/`new`/локальных фильтров уже плотная; вынести построение query-строки в маленький helper, не дублировать строки в каждом `to=`.
|
||||
- **Full-screen вёрстка**: `.main-content` имеет сайдбар и `.page`-отступы; лента shorts должна аккуратно «вырезать» их (negative margins / собственный контейнер), не сломав мобильную раскладку. Высота — через `100dvh` и высоту topbar (66px desktop / 112px mobile).
|
||||
- **`test_activity_item_shape`** проверяет точный набор ключей DTO — без правки теста `pytest` упадёт; включить обновление в задачу Coder.
|
||||
- **Полноэкранность и скролл страницы**: нужно `overscroll-behavior: contain`, чтобы свайп ленты не тянул страницу приложения; для `prefers-reduced-motion` снап-поведение упрощается.
|
||||
- **Правило AGENTS.md 5 (не делать YouTube Home/Recommendations)**: лента Shorts — это не рекомендации, а тот же фильтрованный набор ленты; порядок только `published_at DESC`, без «похожих».
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
- 2026-09-27: документ создан перед реализацией. Зафиксированы решения пользователя: `SHORTS_MAX_DURATION_SECONDS=180` (NULL → long); фильтр `?type=all|long|short` во всех списках видео, кроме режима `?view=channels`; short → `/shorts/:youtubeVideoId`, long → `/video/:youtubeVideoId`; вертикальная лента shorts в контексте (тот же `/api/feed` с `type=short`); без миграций. Добавлены проектные решения Analyst: `is_short` централизованно в `serialize_video`; опциональный `anchor` в `/api/feed` для открытия конкретного ролика; механика ленты — CSS scroll-snap + `IntersectionObserver (threshold 0.6)`, монтаж плеера только для активного слайда; контекст ленты в URL `shorts`-ссылки (`category`/`uncategorized`/`downloaded`/`q`/`channel`).
|
||||
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`.
|
||||
113
analytics/2026-09-28-type-filter-two-modes.md
Normal file
113
analytics/2026-09-28-type-filter-two-modes.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Фильтр типа видео: два режима «Обычные | Shorts» (без «Все»)
|
||||
|
||||
## Задача
|
||||
|
||||
Упростить сегмент-фильтр типа видео: убрать вариант **«Все»**, оставить два взаимоисключающих режима — **«Обычные»** (`long`) и **«Shorts»** (`short`). Всегда выбран ровно один режим.
|
||||
|
||||
Решения пользователя и Analyst, которые нужно зафиксировать:
|
||||
|
||||
1. **По умолчанию** (в URL нет `?type`) — **«Обычные»** (`long`).
|
||||
2. **`?type=all`, `?type=long` и любое неизвестное значение** в URL трактуются как **«Обычные»**. UI больше не предлагает «Все».
|
||||
3. **URL-конвенция:** `?type=short` — для Shorts; для «Обычных» параметр **не нужен** (URL без `?type`). Выбор обоснован ниже.
|
||||
4. При переключении категории/вида сохраняется **только `type=short`** (для «Обычных» сохранять нечего — это отсутствие параметра).
|
||||
5. **Backend `type=all` в API остаётся** без изменений — совместимость REST для будущего mobile/PWA-клиента (AGENTS.md, правило 13). UI этот вариант просто не использует.
|
||||
|
||||
Это уточнение к решению из `analytics/2026-09-27-shorts-split-and-feed.md` (там UI-фильтр был «Все | Обычные | Shorts»); сам документ — исторический, не переписывается.
|
||||
|
||||
## Контекст
|
||||
|
||||
Проверено по фактическому worktree (ветка `master`, правка shorts уже в незакоммиченных изменениях).
|
||||
|
||||
- Сегмент-контрол и парсинг `?type` задублированы в двух местах:
|
||||
- `frontend/src/pages/Feed.tsx:10-14` — `TYPE_OPTIONS` с тремя значениями (`all/long/short`);
|
||||
`:30-31` — `videoType = typeParam === 'short' || typeParam === 'long' ? typeParam : 'all'`;
|
||||
`:36` — сохранение `type` при переключении категории/вида (`if (videoType !== 'all')`);
|
||||
`:39-45` — `typeLink` (`all` → удалить `type`, иначе установить);
|
||||
`:46-53` — `feedViewLink`/`channelsViewSearch` (пробрасывают `type`, если не `all`);
|
||||
`:118,150` — empty-states с веткой `all`.
|
||||
- `frontend/src/pages/ChannelVideos.tsx:9-13,21-29,31,34-35,46` — то же для страницы канала.
|
||||
- `frontend/src/components/AppShell.tsx:67-78` (`Sidebar`) сохраняет `?type` в ссылках «Все видео»/категорий/бейджей: `activeType = currentType === 'short' || currentType === 'long' ? currentType : null`. Комментарий `:67-69` ещё упоминает «Все | Обычные | Shorts».
|
||||
- `frontend/src/utils/videoLinks.ts:13-39` — контекст ссылок `/shorts/:youtubeVideoId` (`category`/`uncategorized`/`downloaded`/`q`/`channel`). Параметр `type` там **не нужен** (лента Shorts всегда `type: 'short'`, `ShortsFeed.tsx:38`). Изменений в файле нет.
|
||||
- `frontend/src/api/client.ts:173` — `VideoType = 'all' | 'long' | 'short'`; `:239,250` — `getFeed` шлёт `type` только если он truthy.
|
||||
- `backend/app/api/feed.py:86` — `video_type: Literal["all","long","short"] = Query("all", alias="type")`; `:124-135` — фильтр по длительности (`short` / `long`, `all` = без фильтра). **Файл не трогаем.**
|
||||
- Backend-тесты `tests/test_shorts_feed.py` (в т.ч. `test_feed_type_all_matches_default`, `?type=all`) проверяют REST и остаются как есть. Frontend-тестов нет, проверка — `npm run lint` + `npm run build`.
|
||||
|
||||
### Ключевой нюанс: URL-конвенция ≠ вызов API
|
||||
|
||||
Дефолт backend `type=all` означает **«без фильтра»**, а не «Обычные». Поэтому frontend **обязан всегда слать `type=long` или `type=short` явно** — опускать параметр нельзя, иначе «Обычные» вернут все видео. URL страницы при этом может не содержать `?type` (это лишь UI-конвенция для «Обычных»).
|
||||
|
||||
### Обоснование URL-конвенции (`Обычные` = нет `?type`)
|
||||
|
||||
- Совпадает с прежним «дефолт не пишем в URL» (`all` раньше не выводился) — минимальная правка `typeLink`/`preservedParams`.
|
||||
- Чистые URL для самого частого режима; «Обычные» и так дефолт.
|
||||
- `?type=long` и `?type=all` из старых закладок продолжают работать (нормализуются в «Обычные»), хотя теперь означают не «все видео», а «Обычные» — это осознанное изменение поведения.
|
||||
- Альтернатива (`?type=long` явно) отвергнута: лишний параметр на каждый переход и дублирование дефолта.
|
||||
|
||||
## Затронутые подсистемы и файлы
|
||||
|
||||
Frontend (единственная меняемая подсистема):
|
||||
|
||||
- `frontend/src/pages/Feed.tsx` — `TYPE_OPTIONS` из двух пунктов; нормализация `videoType: 'long' | 'short'`; `typeLink`/`preservedParams`/`feedViewLink`/`channelsViewSearch` под `type=short`-only; empty-states без ветки `all`; замена CTA «Показать все видео».
|
||||
- `frontend/src/pages/ChannelVideos.tsx` — то же (сегмент-контрол, нормализация, empty-states, CTA).
|
||||
- `frontend/src/components/AppShell.tsx` — `Sidebar`: сохранять только `type=short`; актуализировать комментарий `:67-69`.
|
||||
- `frontend/src/api/client.ts` — **без обязательных изменений**: `VideoType` можно оставить `'all' | 'long' | 'short'` как зеркало REST; UI просто не использует `all`. `getFeed` уже шлёт `type`, если он задан. Если решено сузить тип UI — допустимо.
|
||||
|
||||
Не меняются:
|
||||
|
||||
- `frontend/src/utils/videoLinks.ts` — контекст shorts-ссылок не зависит от `type`.
|
||||
- `frontend/src/pages/ShortsFeed.tsx` — всегда `type: 'short'`.
|
||||
- `backend/**` (в т.ч. `backend/app/api/feed.py`, `config.py`, `video_presentation.py`), `migrations/**`, `tests/**` — без изменений.
|
||||
- `.env.example` / `.env` — без изменений.
|
||||
|
||||
Документация:
|
||||
|
||||
- `README.md:13` — обновить описание фильтра (см. «План», п. 6): «Обычные | Shorts», дефолт «Обычные», `?type=short`; отметить, что REST `type=all` сохранён.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Во всех местах фильтра (`Feed.tsx` — `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`; `ChannelVideos.tsx` — `/channels/:id/videos`) сегмент-контрол содержит **ровно два** пункта: «Обычные» и «Shorts». Варианта «Все» нигде нет.
|
||||
2. Всегда выбран ровно один режим. Без `?type` в URL активен **«Обычные»**; запрос к API идёт с `type=long`.
|
||||
3. `?type=short` → активен «Shorts», запрос идёт с `type=short`.
|
||||
4. `?type=all`, `?type=long` и любое неизвестное значение → активен **«Обычные»** (и запрос с `type=long`); ошибок/пустых страниц нет. `?type=all` больше не означает «все видео».
|
||||
5. Переключение в URL: Shorts → `?type=short`; «Обычные» → параметр `type` удаляется (URL без `?type`).
|
||||
6. При переключении категории (сайдбар, mobile-category-nav, local-filter-nav), вида («Лента | Каналы»), бейджа `?new=1` и при F5/back/forward сохраняется `type=short`; для «Обычных» параметр отсутствует. Никакие переходы не превращают Shorts в «Обычные» и наоборот.
|
||||
7. Ссылки на shorts (`videoLinks.ts` → `/shorts/:youtubeVideoId`) работают как раньше; лента Shorts открывается и подгружается (она всегда `type: 'short'`).
|
||||
8. Режим «Каналы» (`?view=channels`) — без фильтра: сегмент-контрол скрыт, эндпоинт активности `type` не получает. Допустимо переносить `type=short` в URL вида для восстановления состояния при возврате в «Ленту» (фильтр всё равно не применяется), но контрол в этом режиме не показывается.
|
||||
9. Empty-states корректны для двух режимов (например, «Здесь пока нет обычных видео» / «Здесь пока нет Shorts»); неактуальная ветка `all` удалена. Действие «Показать все видео» больше не ведёт на `type=all` (см. п. 4 «Плана»).
|
||||
10. Backend не тронут: `GET /api/feed` по-прежнему принимает `type=all|long|short` с дефолтом `all`; тесты `tests/**` не меняются и проходят.
|
||||
11. `npm run lint` и `npm run build` в `frontend/` чистые.
|
||||
12. Деплой: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## План
|
||||
|
||||
1. **`Feed.tsx`:**
|
||||
- `TYPE_OPTIONS`: удалить `{ value: 'all', label: 'Все' }`; оставить `long` («Обычные», первый) и `short` («Shorts»).
|
||||
- Нормализация: `const videoType: 'long' | 'short' = searchParams.get('type') === 'short' ? 'short' : 'long'` (всё прочее — `long`).
|
||||
- `typeLink(value)`: `value === 'short'` → `params.set('type', 'short')`; иначе `params.delete('type')` (заодно убирает legacy `all`/`long`).
|
||||
- `preservedParams`: `if (videoType === 'short') preservedParams.set('type', 'short')` — сохранение при переходах по категориям/mobile-nav.
|
||||
- `feedViewLink`: `if (videoType === 'short') params.set('type', 'short')`.
|
||||
- `channelsViewSearch`: `view=channels`; `type=short` добавлять только для сохранения состояния (или не добавлять вовсе — зафиксировать в коде комментарием; критерий 8 не нарушается в обоих случаях).
|
||||
- `queryKey` (`:68`) и `getFeed({ type: videoType })` (`:75`) — без изменений по форме, но `videoType` теперь всегда `long`/`short` (явный параметр обязателен, дефолт backend `all` не должен протекать).
|
||||
- `typeEmptyTitle`/`typeEmptyText` и блок empty-state `:150` — убрать ветку `all`; CTA «Показать все видео» либо убрать, либо заменить ссылкой на **другой** режим (см. «Риски», п. про CTA).
|
||||
2. **`ChannelVideos.tsx`:** зеркально: `TYPE_OPTIONS` из двух пунктов, нормализация `short`/`long`, `typeLink` (long → delete, short → set), `queryKey`/`getFeed` всегда с явным `type`, empty-states двухрежимные, CTA без `all`.
|
||||
3. **`AppShell.tsx` (`Sidebar`):** `activeType` = `currentParams.get('type') === 'short' ? 'short' : null`; `typeQuery`/`typeSuffix` строить только для `short`. Обновить комментарий `:67-69` («Все | Обычные | Shorts» → «Обычные | Shorts»).
|
||||
4. **Empty-state CTA (решение Analyst):** так как «Все» больше нет, кнопка «Показать все видео» теряет смысл. В пустом «Обычные» предлагать переход в «Смотреть Shorts» (`typeLink('short')`), в пустом «Shorts» — «Смотреть обычные» (`typeLink('long')`); там, где CTA был скрыт (search/local/sync), поведение сохранить. Допустимо просто убрать CTA — тогда обязательно наличие сегмент-контрола в шапке.
|
||||
5. **`client.ts`:** изменений не требуется — оставить `VideoType = 'all' | 'long' | 'short'` как зеркало REST; UI передаёт только `long`/`short`. (Сужать тип не обязательно; если сужать — убедиться, что нигде не осталось сравнений с `'all'`.)
|
||||
6. **`README.md:13`:** обновить строку: сегмент-фильтр «Обычные | Shorts» (`?type=short` для Shorts; «Обычные» — по умолчанию, без параметра), сохранить описание вертикальной ленты Shorts; добавить, что REST `GET /api/feed` по-прежнему поддерживает `type=all` для будущего mobile-клиента, но UI его не использует.
|
||||
7. **Проверки:** `npm run lint`, `npm run build` в `frontend/`; ручной прогон — default/`?type=all`/`?type=long`/`?type=short`/мусорный `type` на всех точках входа, сохранение при переключении категории/вида/бейджа/F5, режим «Каналы» без контрола, клик по short-карточке → лента. Деплой и health.
|
||||
8. Backend/миграции/тесты — не трогать.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- **Привычка к «Все»:** пользователь мог пользоваться режимом «Все» (он был дефолтом). Теперь дефолт — «Обычные», то есть после обновления без явного выбора Shorts **скрыты**. Это осознанное решение пользователя; митигация — заметный сегмент-контрол и (опционально) empty-state CTA «Смотреть Shorts». Отметить при ручной проверке.
|
||||
- **Слом старых закладок/ссылок `?type=all`:** теперь они открывают «Обычные», а не «все видео». Поведение задокументировано (критерий 4), но это видимое изменение; при необходимости можно один раз редиректить/канонизировать URL — не обязательно.
|
||||
- **Протечка backend-дефолта `all`:** если frontend случайно **опустит** `type` для «Обычных», API вернёт все видео (short + long) — баг. Обязательное требование: `getFeed` всегда вызывается с `type: 'long' | 'short'`. Проверить оба места (`Feed.tsx`, `ChannelVideos.tsx`) и `ShortsFeed.tsx` (там уже `'short'`).
|
||||
- **Сохранение состояния:** легко забыть один из переходов (`typeLink`, `preservedParams`, `feedViewLink`, `channelsViewSearch`, `Sidebar`). Требуется ручная проверка каждого (критерий 6).
|
||||
- **Режим «Каналы»:** фильтр не применяется к `/api/channels/activity`; не добавить `type` в запрос активности и не показать контрол. URL может нести `type=short` как «память» — это не фильтрация.
|
||||
- **Мёртвые ветки `'all'`:** после правки не должно остаться сравнений `videoType !== 'all'`/`typeLink('all')`; иначе пустые состояния или CTA будут вести на удалённый режим. `noUnusedLocals`/type-check (`tsc -b`) помогут отловить часть.
|
||||
- **REST-совместимость (AGENTS.md 13):** backend `type=all` и `Literal` не менять; mobile-клиент в будущем сможет запрашивать все видео. Никаких миграций.
|
||||
- **Комментарии/документация:** обновить комментарии в `Feed.tsx`/`AppShell.tsx`, где ещё упоминается «Все»; `analytics/2026-09-27-shorts-split-and-feed.md` — исторический документ, не править.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
- 2026-09-28: документ создан перед реализацией. Зафиксированы решения пользователя: убрать «Все» из UI-фильтра, оставить «Обычные»/«Shorts»; дефолт — «Обычные»; `?type=all`/`long`/неизвестное → «Обычные»; URL «Обычных» — без `?type`, Shorts — `?type=short`; сохранять только `type=short`; backend `type=all` оставить (REST, правило 13). Решения Analyst: URL-конвенция «Обычные = нет параметра» (обоснована выше); frontend обязан всегда слать явный `type=long|short` (дефолт backend `all` нельзя опускать); `videoLinks.ts`/`ShortsFeed.tsx` не меняются; CTA «Показать все видео» перепрофилируется на переход в другой режим; README обновляется по строке `:13`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue