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:
vrubelroman 2026-09-28 09:33:35 +00:00
parent 43adec5224
commit 252f38597d
25 changed files with 1297 additions and 88 deletions

View file

@ -93,6 +93,10 @@ VIDEOS_KNOWN_STOP_THRESHOLD=50
# и в сайдбаре категорий, а также фильтр «только новые» по клику на число
NEW_VIDEOS_WINDOW_DAYS=2
# Видео короче или равные этому числу секунд считаются Shorts.
# Неизвестная длительность (NULL) считается обычным видео.
SHORTS_MAX_DURATION_SECONDS=180
# ---------- Логи ----------
LOG_LEVEL=INFO

View file

@ -10,6 +10,7 @@
- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
- Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах.
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`.
- Разделение видео на **Shorts** и обычные по длительности (`SHORTS_MAX_DURATION_SECONDS`, по умолчанию 180 сек; неизвестная длительность — обычное видео): сегмент-фильтр «Обычные | Shorts» во всех списках видео (по умолчанию «Обычные», `?type=short` для Shorts; `?type=all`/`?type=long`/неизвестное трактуются как «Обычные») и вертикальная full-screen лента Shorts (`/shorts/:youtubeVideoId`) с автоплеем активного ролика и бесконечной подгрузкой в контексте открытого фильтра. REST `GET /api/feed` по-прежнему поддерживает `type=all|long|short` (совместимость с будущим mobile/PWA-клиентом), UI вариант `all` не использует.
- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов.
- Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`.
@ -17,7 +18,7 @@
### Что осталось / сознательно отложено
- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д.
- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, SponsorBlock и т.д.
- Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch).
## Стек

View 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`).

View 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`.

View 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`.

View file

@ -1,5 +1,6 @@
import base64
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
@ -82,6 +83,8 @@ def get_feed(
downloaded: bool = False,
search: str | None = Query(None, max_length=200),
new_only: bool = False,
video_type: Literal["all", "long", "short"] = Query("all", alias="type"),
anchor: str | None = None,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None,
db: Session = Depends(get_db),
@ -118,6 +121,33 @@ def get_feed(
or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\"))
)
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,
)
)
# The anchor is a one-off starting point for the Shorts feed: only the
# first page (no cursor) is shifted to start at that video and go older.
# Unknown anchors degrade silently to a regular first page.
if anchor and not cursor:
anchor_video = db.query(Video).filter(Video.youtube_video_id == anchor).first()
if anchor_video is not None:
query = query.filter(
(Video.published_at < anchor_video.published_at)
| (
(Video.published_at == anchor_video.published_at)
& (Video.id <= anchor_video.id)
)
)
if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter(

View file

@ -45,6 +45,9 @@ class Settings(BaseSettings):
# we have already synced).
videos_known_stop_threshold: int = 50
new_videos_window_days: int = 2
# Videos with a known duration at or below this threshold (seconds) are
# treated as Shorts; unknown duration (NULL) counts as a regular video.
shorts_max_duration_seconds: int = 180
log_level: str = "INFO"

View file

@ -1,6 +1,7 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
@ -51,6 +52,8 @@ def serialize_video(
"thumbnail_url": video.thumbnail_url,
"published_at": video.published_at,
"duration_seconds": video.duration_seconds,
"is_short": video.duration_seconds is not None
and video.duration_seconds <= settings.shorts_max_duration_seconds,
"youtube_url": video.youtube_url,
"categories": categories,
"local": _serialize_local(download_job),

View file

@ -97,10 +97,11 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
.download-action-error { color: var(--error); font-size: 12px; }
.load-more { display: flex; margin: 34px auto 0; }
.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
.view-toggle a:hover { color: var(--text); background: var(--hover); }
.view-toggle a.active { color: var(--accent); background: var(--accent-soft); }
.page-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.view-toggle, .type-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.view-toggle a, .type-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
.view-toggle a:hover, .type-toggle a:hover { color: var(--text); background: var(--hover); }
.view-toggle a.active, .type-toggle a.active { color: var(--accent); background: var(--accent-soft); }
.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; }
.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; }
@ -252,6 +253,36 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; }
.app-loading h1 { font-size: 24px; }
.app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
.shorts-page { position: relative; background: #000; }
.shorts-feed { position: relative; height: calc(100dvh - 66px); overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scroll-behavior: smooth; background: #000; outline: none; }
.shorts-feed:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.shorts-feed.shorts-centered { display: grid; place-items: center; }
.shorts-slide { position: relative; height: 100%; overflow: hidden; scroll-snap-align: start; scroll-snap-stop: always; background: #000; }
.shorts-player, .shorts-poster { position: absolute; inset: 0; }
.shorts-video { width: 100%; height: 100%; border: 0; object-fit: contain; background: #000; }
.shorts-yt-host > div { width: 100%; height: 100%; }
/* YT.Player replaces our imperatively-created child with an <iframe
width=640 height=360>; size it back to the full slide. */
.shorts-yt-host iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.shorts-poster img { width: 100%; height: 100%; object-fit: cover; }
.shorts-poster .video-thumbnail-placeholder { width: 100%; height: 100%; }
.shorts-overlay { position: absolute; inset: auto 0 0 0; z-index: 2; display: flex; flex-direction: column; gap: 6px; max-width: min(760px, 82%); padding: 22px clamp(28px, 4vw, 54px) 28px; background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.72) 70%); pointer-events: none; }
.shorts-overlay a { pointer-events: auto; }
.shorts-channel { align-self: flex-start; color: #fff; font-size: 15px; font-weight: 700; text-decoration: none; text-shadow: 0 1px 6px rgba(0,0,0,.8); }
.shorts-channel:hover { color: var(--accent); }
.shorts-title { margin: 0; color: #fff; font-size: clamp(16px, 1.6vw, 21px); line-height: 1.35; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
.shorts-meta { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,.75); font-size: 12px; }
.shorts-external { color: rgba(255,255,255,.85); text-decoration: none; font-weight: 600; }
.shorts-external:hover { color: var(--accent); text-decoration: underline; }
.shorts-mute { position: absolute; z-index: 3; right: clamp(16px, 3vw, 34px); bottom: 26px; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid rgba(255,255,255,.28); border-radius: 50%; background: rgba(0,0,0,.5); color: #fff; }
.shorts-mute:hover { background: rgba(0,0,0,.75); }
.shorts-mute:disabled { opacity: .45; cursor: default; }
.shorts-back { position: absolute; z-index: 5; top: 14px; left: 14px; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px 0 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: 13px; font-weight: 600; }
.shorts-back:hover { background: rgba(0,0,0,.8); }
.shorts-sentinel { height: 1px; scroll-snap-align: none; }
.shorts-loading { position: absolute; z-index: 4; left: 50%; bottom: 22px; transform: translateX(-50%); padding: 8px 16px; border-radius: 999px; background: rgba(0,0,0,.66); color: #fff; font-size: 13px; }
.shorts-skeleton .skeleton-thumbnail { border-radius: 0; position: absolute; inset: 0; }
.shorts-skeleton .shorts-overlay { z-index: 1; }
@media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@ -286,7 +317,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.page { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.page-heading h1 { font-size: 27px; }
.view-toggle a { min-height: 38px; padding: 0 12px; }
.view-toggle a, .type-toggle a { min-height: 38px; padding: 0 12px; }
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
.channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
@ -317,15 +348,22 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.category-count { flex-basis: 100%; margin-left: 21px; }
.category-edit-form input { flex-basis: 100%; }
.sync-popover { right: -7px; max-width: calc(100vw - 30px); }
.page-heading { flex-wrap: wrap; }
.page-controls { justify-content: flex-start; }
.shorts-feed { height: calc(100dvh - 112px); }
.shorts-overlay { max-width: 100%; padding: 18px 16px 26px; }
.shorts-back { top: 10px; left: 10px; }
.shorts-mute { right: 16px; bottom: 20px; }
}
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
@media (pointer: coarse) {
.video-actions .button-secondary, .download-badge, .video-page-actions .button-secondary, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
.sidebar-badge { display: inline-flex; align-items: center; }
.view-toggle a { min-height: 44px; }
.view-toggle a, .type-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; }
.popover-create input, .popover-create button { height: 44px; }
.popover-create button { width: 44px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
@media (prefers-reduced-motion: reduce) { .tap-seek-indicator { animation: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .shorts-feed { scroll-snap-type: none; } }

View file

@ -7,6 +7,7 @@ import Feed from './pages/Feed'
import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage'
import ShortsFeed from './pages/ShortsFeed'
import Categories from './pages/Categories'
import Settings from './pages/Settings'
import './App.css'
@ -29,6 +30,7 @@ function App() {
<Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />
<Route path="/settings" element={<Settings email={data.email} />} />
<Route path="/settings/categories" element={<Categories />} />
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} />

View file

@ -170,6 +170,8 @@ export function syncVideos() {
return request<SyncStatus>('/api/sync/videos', { method: 'POST' })
}
export type VideoType = 'all' | 'long' | 'short'
export interface FeedVideoDto {
youtube_video_id: string
title: string
@ -183,6 +185,7 @@ export interface FeedVideoDto {
thumbnail_url: string | null
published_at: string
duration_seconds: number | null
is_short: boolean
youtube_url: string
categories: { id: number; name: string }[]
local: {
@ -233,6 +236,8 @@ export function getFeed(
cursor?: string
limit?: number
newOnly?: boolean
type?: VideoType
anchor?: string
} = {},
) {
const qs = new URLSearchParams()
@ -242,6 +247,8 @@ export function getFeed(
if (params.downloaded) qs.set('downloaded', 'true')
if (params.search) qs.set('search', params.search)
if (params.newOnly) qs.set('new_only', 'true')
if (params.type) qs.set('type', params.type)
if (params.anchor) qs.set('anchor', params.anchor)
if (params.cursor) qs.set('cursor', params.cursor)
if (params.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''

View file

@ -64,12 +64,21 @@ function Sidebar({ close }: { close: () => void }) {
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const location = useLocation()
// Keep the current view mode ("Лента | Каналы") when switching between
// "Все видео" and category links; badges (?new=1) are feed-specific.
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : ''
// Keep the current view mode ("Лента | Каналы") and the video-type filter
// ("Обычные | Shorts") when switching between "Все видео" and category
// links; badges (?new=1) are feed-specific. Only `?type=short` is carried —
// "Обычные" is the default and has no parameter.
const currentParams = new URLSearchParams(location.search)
const preservedParams = new URLSearchParams()
if (currentParams.get('view') === 'channels') preservedParams.set('view', 'channels')
const activeType = currentParams.get('type') === 'short' ? 'short' : null
if (activeType) preservedParams.set('type', activeType)
const viewQuery = preservedParams.toString() ? `?${preservedParams.toString()}` : ''
const typeQuery = activeType ? '?type=short' : ''
const typeSuffix = activeType ? '&type=short' : ''
const links = [
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
{ to: `/local${typeQuery}`, icon: 'server' as const, text: 'На сервере' },
]
return <nav className="sidebar-nav" aria-label="Основная навигация">
<div className="sidebar-group">
@ -79,7 +88,7 @@ function Sidebar({ close }: { close: () => void }) {
<div className="sidebar-heading">Категории</div>
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1${typeSuffix}`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
</div>)}
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>

View file

@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
import { videoHref } from '../utils/videoLinks'
interface Props {
video: FeedVideoDto
@ -8,8 +9,8 @@ interface Props {
function CompactVideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation()
const href = videoHref(video, location)
const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))

View file

@ -1,6 +1,6 @@
import type { SVGProps } from 'react'
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down'
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down' | 'volume' | 'volume-off'
const paths: Record<IconName, React.ReactNode> = {
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
@ -25,6 +25,8 @@ const paths: Record<IconName, React.ReactNode> = {
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
up: <><path d="m6 15 6-6 6 6" /></>,
down: <><path d="m6 9 6 6 6-6" /></>,
volume: <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /></>,
'volume-off': <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="m16 9 5 6M21 9l-5 6" /></>,
}
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {

View file

@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client'
import { recheckLocal } from '../api/client'
import type { YoutubePlayerApi } from '../utils/youtube'
import { loadYouTubeIframeApi } from '../utils/youtube'
const DOUBLE_TAP_WINDOW_MS = 300
const DOUBLE_TAP_MAX_DISTANCE_PX = 40
@ -10,55 +12,6 @@ const SEEK_STEP_SECONDS = 10
const INDICATOR_DURATION_MS = 750
const YT_API_TIMEOUT_MS = 6000
interface YoutubePlayerApi {
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
destroy(): void
}
interface YoutubePlayerCtorOptions {
videoId: string
host?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (event: { target: YoutubePlayerApi }) => void
}
}
declare global {
interface Window {
YT?: {
Player: new (element: HTMLElement, options: YoutubePlayerCtorOptions) => YoutubePlayerApi
}
onYouTubeIframeAPIReady?: () => void
}
}
let youTubeApiLoadPromise: Promise<void> | null = null
function loadYouTubeIframeApi(): Promise<void> {
if (window.YT?.Player) return Promise.resolve()
if (youTubeApiLoadPromise) return youTubeApiLoadPromise
youTubeApiLoadPromise = new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
script.async = true
script.onerror = () => reject(new Error('YouTube IFrame API script failed to load'))
document.head.appendChild(script)
})
youTubeApiLoadPromise = youTubeApiLoadPromise.catch((error: unknown) => {
youTubeApiLoadPromise = null
throw error
})
return youTubeApiLoadPromise
}
function clampSeekTarget(current: number, delta: number, duration: number): number {
const target = current + delta
if (Number.isFinite(duration) && duration > 0) {

View file

@ -0,0 +1,205 @@
import { useEffect, useRef, useState } from 'react'
import type { FeedVideoDto } from '../api/client'
import type { YoutubePlayerApi } from '../utils/youtube'
import { loadYouTubeIframeApi } from '../utils/youtube'
import Icon from './Icon'
const YT_API_TIMEOUT_MS = 6000
const YT_MUTE_POLL_MS = 1000
// mute()/unMute() do not update the widget's cached state immediately (the
// iframe is only polled by www-widgetapi every ~250ms), so sightings right after
// a programmatic change can be stale. Ignore polls for this window.
const YT_MUTE_SUPPRESS_MS = 600
interface Props {
video: FeedVideoDto
muted: boolean
onToggleMute: () => void
onMutedChange: (muted: boolean) => void
}
// The player for a single active Shorts slide. It is mounted only for the
// active slide, so unmounting is what stops playback (no manual pause()).
// Sound is controlled by the parent (`muted` prop) and survives swipes.
function ShortsPlayer({ video, muted, onToggleMute, onMutedChange }: Props) {
const [localFailed, setLocalFailed] = useState(false)
const [ytFailed, setYtFailed] = useState(false)
const videoRef = useRef<HTMLVideoElement | null>(null)
const ytRef = useRef<YoutubePlayerApi | null>(null)
const ytReadyRef = useRef(false)
const hostRef = useRef<HTMLDivElement | null>(null)
// Read inside the player-creation effect without making `muted` a dependency
// (that would recreate YT.Player and reload the video on every toggle).
const mutedRef = useRef(muted)
// Timestamp until which the poll must not trust isMuted().
const suppressPollUntilRef = useRef(0)
const useLocal = video.local.available && !!video.local.media_url && !localFailed
useEffect(() => {
if (useLocal) return
let cancelled = false
let timedOut = false
let child: HTMLDivElement | null = null
let timeoutId: number | null = null
ytRef.current?.destroy()
ytRef.current = null
ytReadyRef.current = false
// Fall back to a plain iframe if the IFrame API does not come up in time.
timeoutId = window.setTimeout(() => {
timeoutId = null
timedOut = true
ytReadyRef.current = false
ytRef.current?.destroy()
ytRef.current = null
if (child?.parentNode) child.remove()
child = null
if (!cancelled) setYtFailed(true)
}, YT_API_TIMEOUT_MS)
loadYouTubeIframeApi()
.then(() => {
if (cancelled || timedOut || !window.YT?.Player) return
const host = hostRef.current
if (!host) return
child = document.createElement('div')
host.appendChild(child)
const player = new window.YT.Player(child, {
videoId: video.youtube_video_id,
host: 'https://www.youtube-nocookie.com',
// Start from the feed-level sound choice. On the first mount this is
// muted, as browser autoplay policies block sound on programmatic
// play; the visible button lets the user opt into sound.
playerVars: { playsinline: 1, autoplay: 1, mute: mutedRef.current ? 1 : 0 },
events: {
onReady: () => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtFailed(false)
ytReadyRef.current = true
// Apply the current value, not the one captured at mount: the
// user may have toggled sound before the player became ready.
if (mutedRef.current) player.mute?.()
else player.unMute?.()
// isMuted() lags behind the call above; ignore polls briefly.
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
},
},
})
ytRef.current = player
})
.catch(() => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtFailed(true)
})
return () => {
cancelled = true
ytReadyRef.current = false
if (timeoutId !== null) window.clearTimeout(timeoutId)
ytRef.current?.destroy()
ytRef.current = null
if (child?.parentNode) child.remove()
child = null
}
}, [useLocal, video.youtube_video_id])
// Keep the ref current (read by onReady) and apply sound changes to an
// already-created YT player. Kept separate from the creation effect so
// toggling never recreates the player. On mount it may run before the player
// exists — initial state is covered by playerVars and onReady.
useEffect(() => {
mutedRef.current = muted
const player = ytRef.current
if (!player) return
if (muted) player.mute?.()
else player.unMute?.()
// The player's own isMuted() is stale until the next infoDelivery; don't
// let the poll read it and revert this change.
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
}, [muted])
// Poll the YouTube player's own mute state so sound changes made with the
// native YouTube control (not our button) propagate to the feed-level state.
// Skips until onReady (isMuted() before ready can throw), stops on unmount,
// and ignores the stale window right after a programmatic change.
useEffect(() => {
if (useLocal) return
const intervalId = window.setInterval(() => {
if (Date.now() < suppressPollUntilRef.current) return
const player = ytRef.current
if (!ytReadyRef.current || !player?.isMuted) return
let observed: unknown
try {
observed = player.isMuted()
} catch {
return
}
// isMuted() may be undefined until the first infoDelivery.
if (typeof observed !== 'boolean') return
if (observed === mutedRef.current) return
// Record before notifying the parent so a re-render cannot re-report it.
mutedRef.current = observed
onMutedChange(observed)
}, YT_MUTE_POLL_MS)
return () => window.clearInterval(intervalId)
}, [useLocal, onMutedChange])
// The plain-iframe fallback always autoplays muted with no JS control, so the
// UI reports "muted" there regardless of the feed-level choice.
const effectivelyMuted = !useLocal && ytFailed ? true : muted
return (
<div className="shorts-player">
{useLocal ? (
<video
ref={videoRef}
className="shorts-video"
autoPlay
playsInline
loop
muted={muted}
controls
src={video.local.media_url!}
onVolumeChange={(event) => {
// Reflect native-control changes back into the feed-level state so
// the next slide keeps the sound choice. "Muted" is only the muted
// flag, matching YouTube; volume 0 is not treated as muted.
const nextMuted = event.currentTarget.muted
if (nextMuted !== muted) onMutedChange(nextMuted)
}}
onError={() => setLocalFailed(true)}
/>
) : ytFailed ? (
<iframe
className="shorts-video"
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}?autoplay=1&playsinline=1&mute=1`}
title={video.title}
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
) : (
<div ref={hostRef} className="shorts-video shorts-yt-host" />
)}
<button
type="button"
className="shorts-mute"
onClick={onToggleMute}
disabled={!useLocal && ytFailed}
aria-label={effectivelyMuted ? 'Включить звук' : 'Выключить звук'}
>
<Icon name={effectivelyMuted ? 'volume-off' : 'volume'} size={18} />
</button>
</div>
)
}
export default ShortsPlayer

View file

@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
import { videoHref } from '../utils/videoLinks'
import DownloadButton from './DownloadButton'
interface Props {
@ -10,8 +11,8 @@ interface Props {
function VideoCard({ video, showActions = false }: Props) {
const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation()
const href = videoHref(video, location)
const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))

View file

@ -1,25 +1,50 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { getChannel, getFeed } from '../api/client'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard'
import { formatSubscriberCount } from '../utils/format'
// Same two-mode filter as the feed: `long` is the default (no URL param),
// `short` is `?type=short`; any other value normalizes to `long`.
type TypeFilter = 'long' | 'short'
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
{ value: 'long', label: 'Обычные' },
{ value: 'short', label: 'Shorts' },
]
function ChannelVideos() {
const { channelId } = useParams<{ channelId: string }>()
const location = useLocation()
const [searchParams] = useSearchParams()
const id = Number(channelId)
const valid = Number.isInteger(id) && id > 0
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
const typeLink = (value: TypeFilter) => {
const params = new URLSearchParams(location.search)
if (value === 'short') params.set('type', 'short')
else params.delete('type')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id, videoType], queryFn: ({ pageParam }) => getFeed({ channelId: id, type: videoType, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
const typeEmptyTitle = videoType === 'short' ? 'У канала пока нет Shorts' : 'У канала пока нет обычных видео'
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
return <section className="page">
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div>
<div className="page-controls"><div className="type-toggle" role="group" aria-label="Тип видео">
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
</div></div>
</div>
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
{feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>{typeEmptyTitle}</h2><p>{typeEmptyText}</p><Link className="button-primary" to={typeLink(videoType === 'short' ? 'long' : 'short')}>{videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'}</Link></div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</section>
}

View file

@ -6,6 +6,17 @@ import ChannelActivityList from '../components/ChannelActivityList'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard'
// The UI exposes exactly two mutually exclusive modes. `long` is the default
// and has no URL parameter; `short` is `?type=short`. The REST endpoint still
// accepts the unfiltered value for future mobile clients, but the UI never
// sends it.
type TypeFilter = 'long' | 'short'
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
{ value: 'long', label: 'Обычные' },
{ value: 'short', label: 'Shorts' },
]
function VideoSkeleton() {
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
}
@ -20,8 +31,37 @@ function Feed() {
const isUncategorized = location.pathname === '/uncategorized'
const supportsViewToggle = !isLocal && location.pathname !== '/search'
const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels'
const viewQuery = isChannelsView ? 'view=channels' : ''
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path
// Any value other than `short` (missing, `all`, `long`, garbage) is
// normalized to the default `long` mode.
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
// Query params carried across sidebar/mobile-category links: the view mode
// and the video-type filter. Only `?type=short` is ever written; `long` is
// the default and has no parameter. `?new=1` stays feed-specific.
const preservedParams = new URLSearchParams()
if (isChannelsView) preservedParams.set('view', 'channels')
if (videoType === 'short') preservedParams.set('type', 'short')
const preservedQuery = preservedParams.toString()
const withPreserved = (path: string) => (preservedQuery ? `${path}${path.includes('?') ? '&' : '?'}${preservedQuery}` : path)
// "На сервере" has no channels view, so the view mode must not leak into its
// URL; keep only the type filter.
const localNavPath = `/local${videoType === 'short' ? '?type=short' : ''}`
const typeLink = (value: TypeFilter) => {
const params = new URLSearchParams(location.search)
if (value === 'short') params.set('type', 'short')
else params.delete('type')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const feedViewLink = () => {
const params = new URLSearchParams()
if (videoType === 'short') params.set('type', 'short')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const channelsViewSearch = new URLSearchParams({ view: 'channels' })
// The filter is not applied in the channels view; carrying `?type=short`
// only restores the user's choice when they switch back to the feed.
if (videoType === 'short') channelsViewSearch.set('type', 'short')
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
@ -36,13 +76,14 @@ function Feed() {
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
})
const feedQuery = useInfiniteQuery({
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly, videoType],
queryFn: ({ pageParam }) => getFeed({
categoryId: isLocal ? localCategory : categoryNumber,
uncategorized: isUncategorized || localUncategorized,
downloaded: isLocal,
search,
newOnly,
type: videoType,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
@ -85,25 +126,46 @@ function Feed() {
}
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
// Empty-state content is derived from the active type filter together with
// the context, so the heading and the hint never contradict each other.
const typeEmptyTitle = videoType === 'short' ? 'Здесь пока нет Shorts' : 'Здесь пока нет обычных видео'
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
// Offer switching to the other type when this context does have content,
// just not of the selected type.
const otherTypeLink = videoType === 'short' ? typeLink('long') : typeLink('short')
const otherTypeLabel = videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'
// `/local`: tell "nothing saved at all" apart from "nothing of this type".
const noSavedVideos = savedCountsQuery.data?.all === 0
// `/uncategorized`: tell "all channels already categorized" apart from
// "uncategorized channels exist but have no videos of this type".
const noUncategorizedChannels = countQuery.data !== undefined && countQuery.data.length === 0
// Root feed with zero subscriptions: onboarding takes priority over the
// type-specific empty-state.
const noSubscriptions = location.pathname === '/' && countQuery.data !== undefined && countQuery.data.length === 0
return <section className="page">
<div className="page-heading">
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
</div>}
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={withPreserved(`/category/${categoryNumber}`)}>Показать все</Link></>}</p></div>
<div className="page-controls">
{!isChannelsView && <div className="type-toggle" role="group" aria-label="Тип видео">
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
</div>}
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
<Link to={feedViewLink()} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
<Link to={{ pathname: location.pathname, search: `?${channelsViewSearch.toString()}` }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
</div>}
</div>
</div>
<nav className="mobile-category-nav" aria-label="Категории">
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
<Link to={withPreserved('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to={withPreserved('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to={localNavPath} className={isLocal ? 'active' : ''}>На сервере</Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
</nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
<Link to={withPreserved('/local')} className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
<Link to={withPreserved('/local?uncategorized=true')} className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/local?category=${item.id}`)} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
</nav>}
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
@ -111,7 +173,7 @@ function Feed() {
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
{feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} showActions={isLocal} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? (noSavedVideos ? 'Пока нет сохранённых видео' : typeEmptyTitle) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы распределены' : typeEmptyTitle) : noSubscriptions ? 'Здесь пока нет видео' : typeEmptyTitle}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? (noSavedVideos ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : typeEmptyText) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы уже распределены по категориям.' : typeEmptyText) : noSubscriptions ? 'Добавь каналы в категорию или обнови ленту.' : typeEmptyText}</p>{!search && !syncRunning && (isLocal ? (!noSavedVideos && <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>) : noSubscriptions ? <Link className="button-primary" to="/channels">Перейти к каналам</Link> : isUncategorized && noUncategorizedChannels ? null : <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>)}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>}
</section>

View file

@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getFeed } from '../api/client'
import Icon from '../components/Icon'
import ShortsPlayer from '../components/ShortsPlayer'
import { formatRelativeTime } from '../utils/format'
function ShortsSkeleton() {
return <div className="shorts-slide shorts-skeleton" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="shorts-overlay"><div className="skeleton-line short" /><div className="skeleton-line wide" /></div></div>
}
function ShortsFeed() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
const location = useLocation()
const navigate = useNavigate()
const from = (location.state as { from?: string } | null)?.from
// The context the feed was opened from travels in the URL, so refresh and
// sharing keep the same filter. See analytics doc, "Соглашение о контексте".
const context = useMemo(() => {
const params = new URLSearchParams(location.search)
const categoryRaw = params.get('category')
const channelRaw = params.get('channel')
return {
categoryId: categoryRaw && /^\d+$/.test(categoryRaw) ? Number(categoryRaw) : undefined,
channelId: channelRaw && /^\d+$/.test(channelRaw) ? Number(channelRaw) : undefined,
uncategorized: params.get('uncategorized') === 'true',
downloaded: params.get('downloaded') === 'true',
search: params.get('q') ?? undefined,
}
}, [location.search])
const query = useInfiniteQuery({
queryKey: ['feed', 'shorts', context, youtubeVideoId],
queryFn: ({ pageParam }) => getFeed({
type: 'short',
...context,
// `anchor` only opens the first page at the requested video; later
// pages use the regular cursor.
anchor: pageParam ? undefined : youtubeVideoId,
cursor: pageParam,
limit: 8,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data])
const { fetchNextPage, hasNextPage, isFetchingNextPage } = query
const [activeIndex, setActiveIndex] = useState(0)
// Sound state lives in the feed, not in the player: the player is mounted
// only for the active slide and would otherwise reset to muted on every
// swipe. Not persisted across reloads on purpose (autoplay policy).
const [muted, setMuted] = useState(true)
const containerRef = useRef<HTMLDivElement | null>(null)
const slideRefs = useRef<(HTMLDivElement | null)[]>([])
const sentinelRef = useRef<HTMLDivElement | null>(null)
const focusedRef = useRef(false)
const lastFocusKeyRef = useRef('')
// Reset to the first (anchor) slide when the context/route changes. Doing
// it during render (instead of in an effect) avoids a stale-frame flicker.
const routeKey = `${youtubeVideoId ?? ''}|${location.search}`
const [lastRouteKey, setLastRouteKey] = useState(routeKey)
if (lastRouteKey !== routeKey) {
setLastRouteKey(routeKey)
setActiveIndex(0)
}
// Focus the scroll container once, so ArrowUp/ArrowDown work without an
// extra Tab. preventScroll keeps the anchor slide in place.
useEffect(() => {
if (lastFocusKeyRef.current !== routeKey) {
lastFocusKeyRef.current = routeKey
focusedRef.current = false
}
if (!focusedRef.current && containerRef.current && items.length > 0) {
containerRef.current.focus({ preventScroll: true })
focusedRef.current = true
}
}, [routeKey, items.length])
// A slide becomes active once it is at least 60% visible.
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
const index = Number((entry.target as HTMLElement).dataset.index)
if (!Number.isNaN(index)) setActiveIndex(index)
}
}
},
{ root: container, threshold: 0.6 },
)
slideRefs.current.forEach((slide) => { if (slide) observer.observe(slide) })
return () => observer.disconnect()
}, [routeKey, items.length])
// Fetch the next page when the sentinel at the end of the list is near.
useEffect(() => {
const sentinel = sentinelRef.current
const container = containerRef.current
if (!sentinel || !container) return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ root: container, rootMargin: '0px 0px 400px 0px', threshold: 0 },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [routeKey, items.length, hasNextPage, isFetchingNextPage, fetchNextPage])
const goToSlide = useCallback((index: number) => {
const slides = slideRefs.current
if (slides.length === 0) return
const target = slides[Math.max(0, Math.min(index, slides.length - 1))]
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
target?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' })
}, [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault()
goToSlide(activeIndex + 1)
} else if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault()
goToSlide(activeIndex - 1)
}
}
const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
if (query.isError) {
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить Shorts</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div></div></div>
}
if (query.isLoading) {
return <div className="shorts-page"><div className="shorts-feed">{Array.from({ length: 2 }, (_, index) => <ShortsSkeleton key={index} />)}</div></div>
}
if (items.length === 0) {
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state"><span className="empty-icon"><Icon name="youtube" size={28} /></span><h2>Здесь пока нет Shorts</h2><p>В этой подборке нет коротких роликов. Вернись к ленте и выбери другой фильтр.</p><Link className="button-primary" to="/">Ко всем видео</Link></div></div></div>
}
return (
<div className="shorts-page">
<button type="button" className="shorts-back" onClick={back}><Icon name="arrow" size={17} /> Назад</button>
<div className="shorts-feed" ref={containerRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="Лента Shorts">
{items.map((video, index) => (
<div
key={video.youtube_video_id}
data-index={index}
ref={(element) => { slideRefs.current[index] = element }}
className={`shorts-slide ${index === activeIndex ? 'is-active' : ''}`}
role="group"
aria-label={`${video.title} — ${video.channel.title}`}
>
{index === activeIndex ? (
<ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((value) => !value)} onMutedChange={setMuted} />
) : (
<div className="shorts-poster">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" loading="lazy" />
) : (
<div className="video-thumbnail-placeholder" />
)}
</div>
)}
<div className="shorts-overlay" inert={index === activeIndex ? undefined : true}>
<Link to={`/channels/${video.channel.id}/videos`} className="shorts-channel" title={video.channel.title}>{video.channel.title}</Link>
<h2 className="shorts-title">{video.title}</h2>
<div className="shorts-meta">
<span>{formatRelativeTime(video.published_at)}</span>
<a href={video.youtube_url} target="_blank" rel="noreferrer" className="shorts-external">Открыть на YouTube</a>
</div>
</div>
</div>
))}
<div ref={sentinelRef} className="shorts-sentinel" aria-hidden="true" />
</div>
{hasNextPage && isFetchingNextPage && <div className="shorts-loading" role="status">Загрузка…</div>}
</div>
)
}
export default ShortsFeed

View file

@ -0,0 +1,40 @@
import type { FeedVideoDto } from '../api/client'
interface LocationLike {
pathname: string
search: string
}
/**
* Where a video card links to: Shorts open the vertical feed at
* `/shorts/:id` (carrying the current list context so the feed keeps the
* same filter), everything else keeps the existing `/video/:id` page.
*/
export function videoHref(video: FeedVideoDto, location: LocationLike): string {
if (!video.is_short) return `/video/${video.youtube_video_id}`
const params = new URLSearchParams()
const { pathname, search } = location
const searchParams = new URLSearchParams(search)
if (pathname.startsWith('/category/')) {
const categoryId = pathname.split('/')[2]
if (categoryId) params.set('category', categoryId)
} else if (pathname === '/uncategorized') {
params.set('uncategorized', 'true')
} else if (pathname === '/local') {
params.set('downloaded', 'true')
const categoryId = searchParams.get('category')
if (categoryId) params.set('category', categoryId)
if (searchParams.get('uncategorized') === 'true') params.set('uncategorized', 'true')
} else if (pathname === '/search') {
const query = searchParams.get('q')
if (query) params.set('q', query)
} else if (pathname.startsWith('/channels/') && pathname.endsWith('/videos')) {
const channelId = pathname.split('/')[2]
if (channelId) params.set('channel', channelId)
}
const query = params.toString()
return `/shorts/${video.youtube_video_id}${query ? `?${query}` : ''}`
}

View file

@ -0,0 +1,52 @@
export interface YoutubePlayerApi {
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
mute?(): void
unMute?(): void
isMuted?(): boolean
getVolume?(): number
destroy(): void
}
export interface YoutubePlayerCtorOptions {
videoId: string
host?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (event: { target: YoutubePlayerApi }) => void
}
}
declare global {
interface Window {
YT?: {
Player: new (element: HTMLElement, options: YoutubePlayerCtorOptions) => YoutubePlayerApi
}
onYouTubeIframeAPIReady?: () => void
}
}
let youTubeApiLoadPromise: Promise<void> | null = null
export function loadYouTubeIframeApi(): Promise<void> {
if (window.YT?.Player) return Promise.resolve()
if (youTubeApiLoadPromise) return youTubeApiLoadPromise
youTubeApiLoadPromise = new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
script.async = true
script.onerror = () => reject(new Error('YouTube IFrame API script failed to load'))
document.head.appendChild(script)
})
youTubeApiLoadPromise = youTubeApiLoadPromise.catch((error: unknown) => {
youTubeApiLoadPromise = null
throw error
})
return youTubeApiLoadPromise
}

View file

@ -248,6 +248,7 @@ def test_activity_item_shape(client, db_session):
"thumbnail_url",
"published_at",
"duration_seconds",
"is_short",
"youtube_url",
"categories",
"local",

171
tests/test_shorts_feed.py Normal file
View file

@ -0,0 +1,171 @@
"""Tests for the Shorts/long split on /api/feed: the `type` filter, the
`is_short` flag and the one-off `anchor` starting point (see
analytics/2026-09-27-shorts-split-and-feed.md)."""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.channel import Channel
from app.models.video import Video
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
BASE = datetime(2026, 9, 10, tzinfo=timezone.utc)
# Insertion order gives increasing ids. published_at also increases with i,
# so the descending feed order is v4, v3, v2, v1, v0.
DURATIONS = {
"v0": None, # unknown -> long
"v1": 179, # short
"v2": 180, # short (boundary, inclusive)
"v3": 181, # long (just above the threshold)
"v4": 100, # short
}
def _seed(db_session):
channel = Channel(youtube_channel_id="chanShorts", title="Channel Shorts", subscribed=True)
db_session.add(channel)
db_session.commit()
videos = []
for i, (youtube_video_id, duration) in enumerate(DURATIONS.items()):
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title=f"Video {youtube_video_id}",
published_at=BASE + timedelta(hours=i),
duration_seconds=duration,
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
videos.append(video)
db_session.add_all(videos)
db_session.commit()
return channel, videos
def _ids(resp):
return [item["youtube_video_id"] for item in resp["items"]]
def test_feed_type_short_only_includes_at_or_below_threshold(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?type=short").json()) == ["v4", "v2", "v1"]
def test_feed_type_long_includes_null_and_above_threshold(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?type=long").json()) == ["v3", "v0"]
def test_feed_type_all_matches_default(client, db_session):
_seed(db_session)
explicit = client.get("/api/feed?type=all").json()
default = client.get("/api/feed").json()
assert _ids(explicit) == ["v4", "v3", "v2", "v1", "v0"]
assert explicit["items"] == default["items"]
def test_feed_type_invalid_returns_422(client, db_session):
_seed(db_session)
assert client.get("/api/feed?type=weird").status_code == 422
def test_feed_type_threshold_is_configurable(client, db_session, monkeypatch):
_seed(db_session)
monkeypatch.setattr("app.config.settings.shorts_max_duration_seconds", 181)
assert set(_ids(client.get("/api/feed?type=short").json())) == {"v4", "v3", "v2", "v1"}
assert _ids(client.get("/api/feed?type=long").json()) == ["v0"]
def test_feed_item_is_short_flag(client, db_session):
_seed(db_session)
items = {item["youtube_video_id"]: item for item in client.get("/api/feed").json()["items"]}
assert items["v1"]["is_short"] is True
assert items["v2"]["is_short"] is True
assert items["v4"]["is_short"] is True
assert items["v3"]["is_short"] is False
assert items["v0"]["is_short"] is False
def test_feed_type_short_pagination_in_filtered_set(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?type=short&limit=2").json()
assert _ids(page1) == ["v4", "v2"]
assert page1["next_cursor"] is not None
page2 = client.get(f"/api/feed?type=short&limit=2&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v1"]
assert page2["next_cursor"] is None
def test_feed_type_long_pagination_in_filtered_set(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?type=long&limit=1").json()
assert _ids(page1) == ["v3"]
assert page1["next_cursor"] is not None
page2 = client.get(f"/api/feed?type=long&limit=1&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v0"]
assert page2["next_cursor"] is None
def test_feed_anchor_starts_at_video_and_goes_older(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?anchor=v2").json()) == ["v2", "v1", "v0"]
def test_feed_anchor_unknown_falls_back_to_first_page(client, db_session):
_seed(db_session)
resp = client.get("/api/feed?anchor=does-not-exist").json()
assert _ids(resp) == ["v4", "v3", "v2", "v1", "v0"]
def test_feed_anchor_ignored_when_cursor_present(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?anchor=v2&limit=2").json()
assert _ids(page1) == ["v2", "v1"]
assert page1["next_cursor"] is not None
# A (different) anchor on a cursor page must not shift the selection:
# only the first page uses the anchor.
page2 = client.get(f"/api/feed?anchor=v4&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v0"]
def test_feed_anchor_combines_with_type_filter(client, db_session):
_seed(db_session)
assert client.get("/api/feed?type=short&anchor=v0").json()["items"] == []
assert _ids(client.get("/api/feed?type=short&anchor=v3").json()) == ["v2", "v1"]

View file

@ -57,6 +57,21 @@ def test_get_video_by_youtube_id(client, db_session):
assert body["channel"]["id"] == channel.id
assert body["categories"] == [{"id": category.id, "name": "Linux"}]
assert body["local"]["available"] is False
# 125s <= default 180s threshold.
assert body["is_short"] is True
def test_get_video_is_short_false_for_long_and_unknown_duration(client, db_session, monkeypatch):
_seed_single_video(db_session, youtube_video_id="vidLong", duration_seconds=3600)
_seed_single_video(db_session, youtube_video_id="vidUnknown")
assert client.get("/api/videos/vidLong").json()["is_short"] is False
assert client.get("/api/videos/vidUnknown").json()["is_short"] is False
# The threshold is read from settings at serialization time.
monkeypatch.setattr("app.config.settings.shorts_max_duration_seconds", 4000)
assert client.get("/api/videos/vidLong").json()["is_short"] is True
assert client.get("/api/videos/vidUnknown").json()["is_short"] is False
def test_get_video_not_found(client):
@ -64,16 +79,19 @@ def test_get_video_not_found(client):
assert resp.status_code == 404
def _seed_single_video(db_session, youtube_video_id="vid1"):
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
db_session.add(channel)
db_session.commit()
def _seed_single_video(db_session, youtube_video_id="vid1", duration_seconds=None):
channel = db_session.query(Channel).filter(Channel.youtube_channel_id == "chanA").first()
if channel is None:
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
db_session.add(channel)
db_session.commit()
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title="Video One",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
duration_seconds=duration_seconds,
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
db_session.add(video)