Add seek controls to the player

- '−10s/+10s' buttons under the player for both modes: local video
  seeks directly, the YouTube embed now uses the official IFrame Player
  API (nocookie host, playsinline) with a plain-iframe fallback if the
  API fails to load.
- Double-tap left/right halves of the local player to seek ±10s with a
  transient indicator (announced via role=status).
This commit is contained in:
vrubelroman 2026-09-25 11:06:33 +00:00
parent fb6e639309
commit a89f972afa
3 changed files with 357 additions and 14 deletions

View file

@ -0,0 +1,88 @@
# Перемотка ±10 сек в плеере: кнопки на обеих ветках + двойной тап в локальном плеере
## Задача
В локальном плеере (ветка `<video controls>` в `frontend/src/components/Player.tsx`) реализовать перемотку двойным тапом: тап по правой половине видео → +10 сек, по левой → −10 сек (как в YouTube), с кратким визуальным индикатором «+10 сек»/«−10 сек» на стороне тапа. Двойной тап в YouTube-embed (iframe) не трогаем: там это встроенное поведение плеера YouTube на мобильных, события внутри кросс-доменного iframe перехватить нельзя, оверлей поверх iframe = костыль.
Расширение скоупа (уточнение пользователя): под плеером для **обеих** веток — кнопки «−10 сек» / «+10 сек». Причина: на iPad в мобильном браузере встроенный двойной тап YouTube-embed нестабилен, поэтому для YouTube-ветки нужен штатный способ перемотки. Штатный способ есть — официальный YouTube IFrame Player API: YouTube-ветка переходит с plain-iframe на `new YT.Player(...)` (скрипт `https://www.youtube.com/iframe_api`, `playerVars: { playsinline: 1 }`), кнопки вызывают `player.seekTo(player.getCurrentTime() ± 10, true)`; фолбэк при недоступности API — plain-iframe как сейчас, кнопки скрыты/disabled. Локальная ветка — кнопки работают напрямую с `video.currentTime` ± 10 и клампингом [0, duration]. Двойной тап остаётся только в локальной ветке.
## Контекст
- `Player.tsx` (59 строк): компонент без refs/effect, только `useState` для `localFailed`. Ветка `useLocal` (строки 24–40) рендерит `<div className="player-wrapper"><video controls src={…} onError={…} /></div>` + `.video-page-source`; ветка YouTube (строки 42–56) — `<iframe …>` в том же `.player-wrapper`. Ранний `return` по `useLocal` → хуки добавлять безусловно до этого return.
- `Player` используется ровно в `VideoPage.tsx` (строка 24) с `key={video.youtube_video_id}` — компонент ремоунтится при смене видео; состояние и обработчики живут не дольше одного видео, но cleanup на unmount обязателен.
- `App.css`: `.player-wrapper` (строка 184, `position: relative; overflow: hidden; aspect-ratio: 16/9`), `.player-wrapper iframe, .player-wrapper video` (строка 185, absolute/inset 0). Media ≤620px (строка 292) меняет только геометрию wrapper. Глобальное `@media (prefers-reduced-motion: reduce)` (строка 314) укорачивает все анимации до .01ms — анимация индикатора у таких пользователей станет мгновенной, сам индикатор останется. Существующие keyframes: `spin` (строка 76), `shimmer` (строка 130).
- `frontend/index.html` (строка 6): viewport `width=device-width, initial-scale=1.0` — **без** `user-scalable=no`/`maximum-scale`, т.е. double-tap-zoom у браузера включён. Viewport менять нельзя (доступность): зум на плеере гасим точечно через `touch-action`.
- Нативные контролы `<video controls>` живут в UA shadow DOM: тап по кнопкам контролов снаружи ретаргетится на host-элемент `video` — событие приходит, как будто тап был по видео. Поэтому факт «тап по контрол-бару» определяется не по `e.target`, а по Y-координате относительно нижней кромки видео.
- Сборка: `npm run build` = `tsc -b && vite build` (`noUnusedLocals: true`, tsconfig.app.json строка 20), `npm run lint` = oxlint. Тестовой инфраструктуры у frontend нет — проверки ручные + lint/build.
- **Причина кнопок для YouTube-ветки**: на iPad в мобильном браузере встроенный двойной тап embed-плеера работает ненадёжно, а перехватить жесты внутри кросс-доменного iframe нельзя — единственный штатный способ управлять embed-плеером извне это IFrame Player API (официальный, документированный).
- Текущий iframe YouTube-ветки: `src="https://www.youtube-nocookie.com/embed/{id}"` — **nocookie**-хост, параметра `playsinline` сейчас **нет** (уточнение к исходной формулировке задачи). IFrame Player API поддерживает опцию `host: 'https://www.youtube-nocookie.com'` (сохраняет текущую приватность без cookie) и `playerVars: { playsinline: 1 }` (инлайн-воспроизведение на iOS).
- Ссылки «Открыть на YouTube» на странице видео нет (grep по `frontend/src` — 0 вхождений) — ломать нечего; `title`/`allow`/`allowFullScreen` у plain-iframe сохраняются в фолбэке, у API-плеера iframe создаёт сам API (см. Решения).
- CSP-заголовков в backend нет (grep по `backend/` — пусто) — динамическая загрузка внешнего скрипта API не будет заблокирована.
- Класса `.video-page-youtube-actions` в CSS нет; есть `.video-page-source` (строка 186) и media-правило для него (строка 293). Ряд кнопок добавляем новым классом под плеером рядом с подписью источника. Правило `.player-wrapper iframe, .player-wrapper video` (строка 185, absolute/inset 0) стилизует **любой** iframe внутри wrapper, включая iframe, создаваемый `YT.Player`.
## Затронутые подсистемы и файлы
Только frontend. Backend, тесты, миграции, README — не трогаем.
- `frontend/src/components/Player.tsx` — refs/state/effect для детекции двойного тапа и индикатора (только в локальной ветке); кнопки «−10 сек»/«+10 сек» под плеером на обеих ветках; YouTube-ветка: динамический загрузчик IFrame Player API, создание/уничтожение `YT.Player` с фолбэком на plain-iframe; контейнер-хост для YT.Player (div с ref внутри `.player-wrapper`).
- `frontend/src/App.css` — стили `.tap-indicator` + keyframes, `touch-action: manipulation` на видео плеера; стили ряда кнопок перемотки под плеером (новый класс `.video-page-actions`) и host-контейнера YT.Player; при необходимости media ≤620px / pointer:coarse для кнопок.
- `frontend/index.html`, `frontend/src/pages/VideoPage.tsx` — без изменений.
## Решения (зафиксированы)
1. **Pointer-события на `video`, двойной тап по `pointerup`.** Слушатели `pointerup` вешаются на элемент `video` локальной ветки (не на wrapper: в локальной ветке wrapper содержит только video, но rect-вычисления и fullscreen-логика естественнее на самом элементе). Требования к тапу: `e.isPrimary === true` (игнор мультитача), `e.button === 0`; двойной тап = два `pointerup` с интервалом ≤ 300 мс и расстоянием между точками < 40 px (отсекает свайпы; вертикальный свайп со скроллом страницы вообще даёт `pointercancel`, а не `pointerup`). Сторона определяется по `clientX` относительно `getBoundingClientRect()` видео: левее середины → −10, правее → +10. Константы 300/40/10 — локальные const в компоненте.
2. **Контрол-бар не считается.** Тап игнорируется, если `clientY > rect.bottom − 80` (константа-запас под высоту нативных контролов 40–80 px в разных браузерах/fullscreen). Без этого тапы по кнопкам контролов (ретаргет на host) давали бы ложные перемотки.
3. **Seek:** `video.currentTime = clamp(currentTime ± 10, 0, duration)`. При недогруженных метаданных `duration` может быть NaN — тогда гарантируем только нижнюю границу 0 (верхнюю браузер клампит сам). После успешного срабатывания сбрасывать состояние «первого тапа», чтобы третий быстрый тап не вызвал повторный seek.
4. **Индикатор:** локальный state `{ delta: +10 | −10, side: 'left' | 'right', id: number }` (id — счётчик для рестарта анимации при повторных тапах). Текст «+10 сек»/«−10 сек» (минус типографский «−»). Позиция — на стороне тапа (CSS-классы `.left`/`.right`, ~25–30% от края, центр по вертикали). `pointer-events: none` — не мешает ни видео, ни контролам. Таймер ~750 мс в ref: по истечении индикатор убирается из DOM, при новом тапе таймер перезапускается. Анимация — появление + затухание через `@keyframes`.
5. **`touch-action: manipulation` на видео** (правило `.player-wrapper video`): отключает double-tap-zoom браузера на всей поверхности плеера (включая область нативных контролов — они в shadow DOM video), сохраняет панорамирование (скролл страницы свайпом по видео) и pinch-zoom. Viewport в `index.html` не меняем. Для iframe-ветки правило безвредно (на iframe `touch-action` не вешаем, поведение YouTube не трогаем).
6. **Одиночный тап и контролы не ломаются:** обработчики **не** вызывают `preventDefault`/`stopPropagation` по пути, не мешающему нативному toggle play/pause и кликам по контролам; seek происходит только при распознанном двойном тапе.
7. **Десктоп (мышь):** фильтр по `pointerType` НЕ добавляем — двойной клик мышью тоже даёт ±10 сек. Обоснование: это в точности повторяет поведение YouTube на десктопе (двойной клик по видео = перемотка); нативный `<video>` не имеет собственного действия на double-click, конфликта нет; Y-защита отсекает клики по контролам; одиночный клик (pause/play) не затрагивается, т.к. seek только на втором клике в окне 300 мс. Отсечение мыши усложнило бы код без выигрыша.
8. **YouTube-ветка: перемотка только кнопками, двойной тап не трогаем.** Двойной тап в embed — встроенное поведение плеера YouTube, кросс-доменные события недоступны, оверлей = костыль. Надёжный штатный путь (в т.ч. для iPad, где встроенный двойной тап нестабилен) — кнопки «−10 сек»/«+10 сек» под плеером, работающие через IFrame Player API (п. 10–12).
9. **Cleanup:** `useEffect` при `useLocal === true` вешает слушатели на `videoRef.current`; возвращаемая cleanup-функция снимает их, чистит таймер индикатора и сбрасывает состояние последнего тапа. Хуки объявляются до раннего return (структура компонента сейчас не позволяет хуки после `if (useLocal)`). При ветке iframe effect no-op (video отсутствует), при ремоунте по `key` всё подчищается.
10. **YouTube IFrame Player API.** В YouTube-ветке вместо статичного `<iframe>` — хост `<div ref={ytHostRef}>` внутри `.player-wrapper`, в который `new YT.Player(host, { videoId, host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1 }, events: { onReady } })` встраивает свой iframe. `host: youtube-nocookie.com` сохраняет текущую приватность, `playsinline: 1` — инлайн на iOS. Стили: iframe от API попадает под существующее правило `.player-wrapper iframe` (строка 185); самому host-div задать заполнение wrapper (`position: absolute; inset: 0` — отдельный класс/расширение селектора). Скрипт `https://www.youtube.com/iframe_api` загружается динамически и идемпотентно (один script-тег на страницу, проверка `window.YT?.Player`/`onYouTubeIframeAPIReady`), только при рендере YouTube-ветки — на локальных видео API не грузится. Загрузчик оформляется промисом (resolve по onload скрипта + готовности `YT.Player`), чтобы не перезаписывать глобальную `onYouTubeIframeAPIReady` наивно.
11. **Фолбэк без API.** Если `YT.Player` не появился за таймаут ~5–8 с (youtube.com недоступен, блокировщик, медленная сеть), рендерим plain-iframe как сейчас (`https://www.youtube-nocookie.com/embed/{id}` с `title`/`allow`/`allowFullScreen`), кнопки перемотки скрываем (или `disabled` + aria). Состояние «loading → ready | timeout» — локальный state; таймер сбрасывается при unmount. В фолбэке поведение не хуже текущего.
12. **Кнопки перемотки.** Ряд (новый класс, напр. `.video-page-actions`) под плеером рядом с `.video-page-source`, на обеих ветках: `<button className="button-secondary" aria-label="Назад на 10 секунд">−10 сек</button>` и `aria-label="Вперёд на 10 секунд">+10 сек</button>`. Локальная ветка: `video.currentTime = clamp(currentTime ± 10, 0, duration)` (NaN-duration — только нижняя граница 0). YouTube-ветка: `player.seekTo(clamp(player.getCurrentTime() ± 10, 0, player.getDuration()), true)`; при незагруженных метаданных `getDuration()` может вернуть 0/NaN — нижнюю границу держим 0, верхнюю не ограничиваем (seekTo обработает). Локально кнопки активны всегда; в YouTube-ветке — после `onReady`, до этого `disabled` (в фолбэке — скрыты/disabled). Стиль `button-secondary` уже есть (App.css строки 59/63); при необходимости — min-height в media ≤620px/pointer:coarse по аналогии с существующими правилами.
13. **Cleanup YouTube-ветки:** при unmount или переключении ветки (local→YouTube при `onError`, смена видео по `key`) — `player.destroy()`, снятие таймеров загрузки/фолбэка, сброс состояния. Ремоунт по `key={video.youtube_video_id}` в `VideoPage` пересоздаёт плеер под новое видео — ожидаемо и корректно.
14. **Типизация YT.** Пакета `@types/youtube` нет; tsconfig без `strict`, но с `verbatimModuleSyntax` и `noUnusedLocals`. Добавить минимальный локальный `declare global { interface Window { YT?: { Player: ... }; onYouTubeIframeAPIReady?: () => void } }` (отдельный `frontend/src/types/youtube.d.ts` или в компоненте): `YT.Player` (ctor, `seekTo`, `getCurrentTime`, `getDuration`, `destroy`, `getIframe`). Импорты типов — только `import type`.
## Критерии приёмки
1. На мобильном (тач): двойной тап по правой половине локального видео → +10 сек, по левой → −10 сек; у краёв диапазона значение клампится в [0, duration]; каждый сработавший жест показывает индикатор «+10 сек»/«−10 сек» на соответствующей стороне, исчезающий через ~0,7–0,9 с.
2. Одиночный тап по видео — play/pause как раньше; тапы по нативным контролам (play, полоса перемотки, громкость, fullscreen) не вызывают перемотку и работают как раньше.
3. Браузер не приближает плеер double-tap-zoom (touch-action), при этом pinch-zoom и скролл страницы работают.
4. Десктоп не сломан: одиночный клик — play/pause; двойной клик — ±10 сек (осознанное решение, п. 7).
5. Кнопки «−10 сек»/«+10 сек» под плеером есть на обеих ветках: локальная — перемотка с клампингом [0, duration]; YouTube — через `seekTo(getCurrentTime() ± 10, true)`, работает в том числе в браузере iPad.
6. YouTube-ветка работает через IFrame Player API (nocookie-хост, playsinline): воспроизведение, встроенные контролы, fullscreen — как раньше; двойной тап по embed не перехватывается, никаких оверлеев поверх iframe.
7. Фолбэк: при недоступности API за ~5–8 с рендерится plain-iframe как сейчас, кнопки скрыты/disabled; страница и подпись источника не ломаются, ошибок в консоли нет (кроме ожидаемых сетевых к youtube.com).
8. Кнопки доступны (aria-label), стиль `button-secondary`, не ломают подпись источника и DownloadButton на странице видео.
9. Нет утечек: при размонтировании (уход со страницы видео, смена видео по key, переход local→YouTube при `onError`) сняты pointer-слушатели, таймер индикатора и таймер фолбэка; `YT.Player` уничтожен через `destroy()`.
10. `npm run lint` и `npm run build` в `frontend/` чистые.
11. Backend не тронут: `git status` — изменения только в `frontend/` и `analytics/`; `pytest` не требуется.
12. Деплой по правилу: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## План
1. `Player.tsx`, локальная ветка: добавить `videoRef`, `lastTapRef` (`{ time, x, y } | null`), `tapIndicator` state (`{ delta, side, id } | null`), `indicatorTimerRef`; `useEffect([useLocal])` — при локальной ветке `pointerup`-обработчик на видео (детектор двойного тапа по п. 1–3, `isPrimary`, `button === 0`, окно 300 мс, порог 40 px, Y-защита 80 px, определение стороны, seek с клампингом, сброс lastTap после срабатывания, показ индикатора + перезапуск таймера); cleanup (п. 9). В разметке локальной ветки — `ref={videoRef}` на `<video>` и условный `<div className={"tap-indicator " + side} key={id}>±10 сек</div>` внутри `.player-wrapper`.
2. `Player.tsx`, кнопки и API: хук-структура до раннего return (`useLocal`): `ytHostRef`, `playerRef`, state API (`'loading' | 'ready' | 'timeout'`), загрузчик скрипта IFrame API (промис, идемпотентный, только для YouTube-ветки). `useEffect` YouTube-ветки: загрузка API → `new YT.Player(ytHostRef.current, { videoId, host: youtube-nocookie, playerVars: { playsinline: 1 }, events: { onReady } })`; таймаут ~5–8 с → state `timeout` (рендер plain-iframe как сейчас); cleanup — `destroy()`, сброс таймера. Рендер YouTube-ветки: хост-div (до ready — пустой/спиннер не обязателен) или фолбэк-iframe; ряд `.video-page-actions` с двумя кнопками на обеих ветках (YouTube: `disabled` до `onReady`, скрыты/disabled в фолбэке; локальная: активны, seek через `videoRef`).
3. `App.css`: `.tap-indicator` (absolute, `top: 50%`, `translateY(-50%)`, `.left`/`.right` по стороне, `pointer-events: none`, полупрозрачная плашка в духе существующих цветов var(--text)/var(--bg), текст) + `@keyframes` появления/затухания (длительность ≈ таймеру, `forwards`); в правило `.player-wrapper video` добавить `touch-action: manipulation`; host-контейнер YT.Player — заполнение wrapper (`position: absolute; inset: 0`); ряд `.video-page-actions` (gap, размещение рядом с `.video-page-source`), при необходимости min-height кнопок в media ≤620px/pointer:coarse.
4. Типизация: `declare global` для `window.YT`/`onYouTubeIframeAPIReady` (минимальные типы, `import type`).
5. Проверки: `npm run lint`, `npm run build`; ручная — DevTools-эмуляция тача (двойной тап обеих сторон, одиночный тап, контролы, зум) и десктоп (клик/двойной клик); кнопки на обеих ветках (локальная и YouTube с API), фолбэк при недоступности API (DevTools offline/блокировка youtube.com), iPad-браузер по возможности; деплой + health.
## Риски и ограничения
- **iOS Safari и `touch-action: manipulation`**: на старых iOS поддержка ограничена — double-tap-zoom на плеере может сохраниться; это приемлемый минорный риск, viewport и pinch-zoom не трогаем (доступность).
- **Константа 80 px для контрол-бара**: в полноэкранном режиме/других браузерах высота контролов может отличаться; тап чуть выше контролов может быть засчитан как видео — допустимо, важнее не ломать сами контролы.
- **`prefers-reduced-motion`**: глобальное правило укорачивает анимацию индикатора до .01ms — индикатор мгновенно появится/исчезнет; функциональность перемотки не страдает.
- **Двойной клик на десктопе**: быстрые два клика по видео дают перемотку вместе с двойным переключением play/pause — соответствует поведению YouTube, принято осознанно.
- **Хуки и ранний return**: эффект/refs должны быть объявлены до `if (useLocal)`; при рефакторинге не сломать ветку iframe (effect должен корректно no-op).
- **Не вызывать `preventDefault`** на pointerup — иначе сломается нативный toggle play/pause и контролы.
- **Загрузка внешнего скрипта API**: youtube.com может быть недоступен/заблокирован (сеть, блокировщики) — покрыто фолбэком на plain-iframe с таймаутом ~5–8 с; до `onReady` кнопки `disabled` (краткая неактивность допустима).
- **`host: youtube-nocookie.com` у YT.Player**: опция документирована в IFrame Player API; если в конкретном окружении поведение окажется некорректным — деградация до обычного `youtube.com`-хоста, некритично (проверить при реализации).
- **`getDuration()` до готовности метаданных**: может вернуть 0/NaN — клампить только нижнюю границу (0), верхнюю доверить `seekTo(..., true)`.
- **Стили host-div**: iframe, создаваемый `YT.Player`, должен попасть под правило `.player-wrapper iframe` (строка 185); самому host-div задать `position: absolute; inset: 0`, иначе плеер не заполнит wrapper (aspect-ratio 16/9 держит wrapper).
- **Типизация/линт**: `@types/youtube` не установлен — локальный `declare global` для `window.YT`; `verbatimModuleSyntax` требует `import type` для типов; `noUnusedLocals` — не оставлять мёртвые импорты после рефакторинга ветки iframe.
## Журнал изменений
- 2026-09-18: документ создан перед реализацией. Зафиксированы решения: pointer-события на `video` (pointerup, окно 300 мс, порог 40 px, isPrimary/button 0); Y-защита 80 px от контрол-бара (тапы по UA shadow-контролам ретаргетятся на host); seek ±10 с клампингом [0, duration]; индикатор в state + таймер ~750 мс, `pointer-events: none`, позиция по стороне тапа; `touch-action: manipulation` на видео, viewport не меняется; мышь не отсекается (совпадает с десктоп-поведением YouTube); YouTube-ветка не трогается; cleanup на unmount; README не трогаем; backend не трогаем.
- 2026-09-18 (расширение скоупа по уточнению пользователя): на iPad в мобильном браузере двойной тап YouTube-embed нестабилен → добавляются кнопки «−10 сек»/«+10 сек» под плеером на **обеих** ветках. YouTube-ветка переходит с plain-iframe на официальный YouTube IFrame Player API: скрипт `https://www.youtube.com/iframe_api` (динамически, идемпотентно, только для YouTube-ветки), `new YT.Player(host, { videoId, host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1 }, events: { onReady } })`, кнопки — `player.seekTo(player.getCurrentTime() ± 10, true)`; фолбэк при таймауте ~5–8 с — plain-iframe как сейчас, кнопки скрыты/disabled. Локальная ветка — кнопки напрямую через `video.currentTime` с клампингом [0, duration]; двойной тап остаётся только локально. Факты по коду (проверено): текущий src — `youtube-nocookie.com/embed/{id}` **без** `playsinline`; ссылки «Открыть на YouTube» в коде нет; CSP в backend нет (внешний скрипт не блокируется); правило `.player-wrapper iframe` (App.css:185) покроет iframe от API, host-div нужен `absolute; inset: 0`; класса `.video-page-youtube-actions` нет — вводим новый ряд кнопок. Cleanup YouTube-ветки: `player.destroy()`, сброс таймеров. README не трогаем; backend не трогаем.

View file

@ -183,7 +183,22 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.video-page { max-width: 1320px; } .video-page { max-width: 1320px; }
.player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; } .player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; }
.player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; } .player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.video-page-source { margin: 11px 0 22px; color: var(--muted); font-size: 12px; } .player-wrapper video { touch-action: manipulation; }
.yt-player-host { position: absolute; inset: 0; }
.video-page-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 11px 0 22px; }
.video-page-meta-row .video-page-source { margin: 0; }
.video-page-source { color: var(--muted); font-size: 12px; }
.video-page-actions { display: flex; align-items: center; gap: 8px; }
.video-page-actions .button-secondary { min-height: 36px; padding: 0 13px; }
.tap-seek-indicator { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; padding: 10px 15px; border-radius: 10px; background: rgba(0, 0, 0, .66); color: #fff; font-size: 13px; font-weight: 650; pointer-events: none; animation: tap-seek-fade .75s ease-out forwards; }
.tap-seek-indicator.left { left: 26%; }
.tap-seek-indicator.right { right: 26%; }
@keyframes tap-seek-fade {
0% { opacity: 0; transform: translateY(-50%) scale(.92); }
10% { opacity: 1; transform: translateY(-50%) scale(1); }
70% { opacity: 1; }
100% { opacity: 0; transform: translateY(-50%) scale(1); }
}
.video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; } .video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; }
.video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; } .video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; }
.video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; } .video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; }
@ -277,7 +292,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.video-thumbnail { border-radius: 9px; } .video-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; } .video-info { padding: 10px 2px 0; }
.video-actions { padding-top: 11px; } .video-actions { padding-top: 11px; }
.video-actions .button-secondary, .download-badge { min-height: 44px; } .video-actions .button-secondary, .download-badge, .video-page-actions .button-secondary { min-height: 44px; }
.channels-toolbar { align-items: stretch; } .channels-toolbar { align-items: stretch; }
.manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; } .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
.manage-link svg { flex: none; } .manage-link svg { flex: none; }
@ -290,7 +305,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.local-filter-nav a { min-height: 44px; } .local-filter-nav a { min-height: 44px; }
.video-detail-head { flex-direction: column; gap: 9px; } .video-detail-head { flex-direction: column; gap: 9px; }
.player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); } .player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); }
.video-page-source { margin-top: 9px; } .video-page-meta-row { gap: 6px 12px; }
.settings-grid { grid-template-columns: 1fr; } .settings-grid { grid-template-columns: 1fr; }
.settings-panel { padding: 18px; } .settings-panel { padding: 18px; }
.category-form { flex-wrap: wrap; } .category-form { flex-wrap: wrap; }
@ -304,7 +319,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
} }
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } } @media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
@media (pointer: coarse) { @media (pointer: coarse) {
.video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; } .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; } .sidebar-badge { display: inline-flex; align-items: center; }
.view-toggle a { min-height: 44px; } .view-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; } .reorder-buttons .icon-button { width: 40px; height: 40px; }
@ -312,3 +327,4 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.popover-create button { width: 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) { *, *::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; } }

View file

@ -1,14 +1,92 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { recheckLocal } from '../api/client' import { recheckLocal } from '../api/client'
const DOUBLE_TAP_WINDOW_MS = 300
const DOUBLE_TAP_MAX_DISTANCE_PX = 40
const CONTROL_BAR_MARGIN_PX = 80
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) {
return Math.min(Math.max(target, 0), duration)
}
// duration ещё не загружена: гарантируем только нижнюю границу, верхнюю клампит браузер
return Math.max(target, 0)
}
interface TapIndicator {
delta: number
side: 'left' | 'right'
id: number
}
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
} }
function Player({ video }: Props) { function Player({ video }: Props) {
const [localFailed, setLocalFailed] = useState(false) const [localFailed, setLocalFailed] = useState(false)
const [tapIndicator, setTapIndicator] = useState<TapIndicator | null>(null)
const [ytApiStatus, setYtApiStatus] = useState<'loading' | 'ready' | 'timeout'>('loading')
const videoRef = useRef<HTMLVideoElement | null>(null)
const lastTapRef = useRef<{ time: number; x: number; y: number } | null>(null)
const indicatorTimerRef = useRef<number | null>(null)
const ytHostRef = useRef<HTMLDivElement | null>(null)
const ytPlayerRef = useRef<YoutubePlayerApi | null>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const recheckMutation = useMutation({ const recheckMutation = useMutation({
@ -21,11 +99,157 @@ function Player({ video }: Props) {
const useLocal = video.local.available && !!video.local.media_url && !localFailed const useLocal = video.local.available && !!video.local.media_url && !localFailed
// Сброс статуса YouTube-плеера при входе в ветку YouTube (рендер-фазная корректировка
// состояния, чтобы не вызывать setState синхронно в теле effect).
const [prevUseLocal, setPrevUseLocal] = useState(useLocal)
if (prevUseLocal !== useLocal) {
setPrevUseLocal(useLocal)
setYtApiStatus('loading')
}
// Двойной тап в локальной ветке: pointerup на video, окно 300 мс, порог 40 px,
// Y-защита от нативных контролов, seek ±10 сек с клампингом и индикатор.
useEffect(() => {
if (!useLocal) return
const videoEl = videoRef.current
if (!videoEl) return
const onPointerUp = (event: PointerEvent) => {
if (!event.isPrimary || event.button !== 0) return
const rect = videoEl.getBoundingClientRect()
if (event.clientY > rect.bottom - CONTROL_BAR_MARGIN_PX) return
const now = Date.now()
const last = lastTapRef.current
const isDoubleTap =
last !== null &&
now - last.time <= DOUBLE_TAP_WINDOW_MS &&
Math.hypot(event.clientX - last.x, event.clientY - last.y) < DOUBLE_TAP_MAX_DISTANCE_PX
lastTapRef.current = { time: now, x: event.clientX, y: event.clientY }
if (!isDoubleTap) return
lastTapRef.current = null
const side: 'left' | 'right' = event.clientX < rect.left + rect.width / 2 ? 'left' : 'right'
const delta = side === 'left' ? -SEEK_STEP_SECONDS : SEEK_STEP_SECONDS
videoEl.currentTime = clampSeekTarget(videoEl.currentTime, delta, videoEl.duration)
setTapIndicator((prev) => ({ delta, side, id: (prev?.id ?? 0) + 1 }))
if (indicatorTimerRef.current !== null) window.clearTimeout(indicatorTimerRef.current)
indicatorTimerRef.current = window.setTimeout(() => {
setTapIndicator(null)
indicatorTimerRef.current = null
}, INDICATOR_DURATION_MS)
}
videoEl.addEventListener('pointerup', onPointerUp)
return () => {
videoEl.removeEventListener('pointerup', onPointerUp)
lastTapRef.current = null
if (indicatorTimerRef.current !== null) {
window.clearTimeout(indicatorTimerRef.current)
indicatorTimerRef.current = null
}
}
}, [useLocal])
// YouTube-ветка: динамическая идемпотентная загрузка IFrame Player API,
// создание YT.Player в host-div с nocookie-хостом, фолбэк на plain-iframe по таймауту.
useEffect(() => {
if (useLocal) return
let cancelled = false
let timedOut = false
let timeoutId: number | null = null
ytPlayerRef.current?.destroy()
ytPlayerRef.current = null
timeoutId = window.setTimeout(() => {
timeoutId = null
timedOut = true
ytPlayerRef.current?.destroy()
ytPlayerRef.current = null
if (!cancelled) setYtApiStatus('timeout')
}, YT_API_TIMEOUT_MS)
loadYouTubeIframeApi()
.then(() => {
if (cancelled || timedOut || !window.YT?.Player) return
const host = ytHostRef.current
if (!host) return
const player = new window.YT.Player(host, {
videoId: video.youtube_video_id,
host: 'https://www.youtube-nocookie.com',
playerVars: { playsinline: 1 },
events: {
onReady: () => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtApiStatus('ready')
},
},
})
ytPlayerRef.current = player
})
.catch(() => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtApiStatus('timeout')
})
return () => {
cancelled = true
if (timeoutId !== null) window.clearTimeout(timeoutId)
ytPlayerRef.current?.destroy()
ytPlayerRef.current = null
}
}, [useLocal, video.youtube_video_id])
const seekBy = (delta: number) => {
if (useLocal) {
const videoEl = videoRef.current
if (!videoEl) return
videoEl.currentTime = clampSeekTarget(videoEl.currentTime, delta, videoEl.duration)
} else {
const player = ytPlayerRef.current
if (!player) return
player.seekTo(Math.max(player.getCurrentTime() + delta, 0), true)
}
}
const seekButtons = (
<div className="video-page-actions">
<button
type="button"
className="button-secondary"
aria-label="Перемотать назад на 10 секунд"
disabled={!useLocal && ytApiStatus !== 'ready'}
onClick={() => seekBy(-SEEK_STEP_SECONDS)}
>
−10 сек
</button>
<button
type="button"
className="button-secondary"
aria-label="Перемотать вперёд на 10 секунд"
disabled={!useLocal && ytApiStatus !== 'ready'}
onClick={() => seekBy(SEEK_STEP_SECONDS)}
>
+10 сек
</button>
</div>
)
if (useLocal) { if (useLocal) {
return ( return (
<> <>
<div className="player-wrapper"> <div className="player-wrapper">
<video <video
ref={videoRef}
controls controls
src={video.local.media_url!} src={video.local.media_url!}
onError={() => { onError={() => {
@ -33,8 +257,16 @@ function Player({ video }: Props) {
recheckMutation.mutate() recheckMutation.mutate()
}} }}
/> />
{tapIndicator && (
<div role="status" className={`tap-seek-indicator ${tapIndicator.side}`} key={tapIndicator.id}>
{tapIndicator.delta > 0 ? '+10 сек' : '−10 сек'}
</div>
)}
</div>
<div className="video-page-meta-row">
<div className="video-page-source">Источник: локальная копия</div>
{seekButtons}
</div> </div>
<div className="video-page-source">Источник: локальная копия</div>
</> </>
) )
} }
@ -42,15 +274,22 @@ function Player({ video }: Props) {
return ( return (
<> <>
<div className="player-wrapper"> <div className="player-wrapper">
<iframe {ytApiStatus === 'timeout' ? (
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`} <iframe
title={video.title} src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" title={video.title}
allowFullScreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/> allowFullScreen
/>
) : (
<div ref={ytHostRef} className="yt-player-host" />
)}
</div> </div>
<div className="video-page-source"> <div className="video-page-meta-row">
{localFailed ? 'Локальная копия недоступна. Переключено на YouTube.' : 'Источник: YouTube'} <div className="video-page-source">
{localFailed ? 'Локальная копия недоступна. Переключено на YouTube.' : 'Источник: YouTube'}
</div>
{seekButtons}
</div> </div>
</> </>
) )