myYouTube/analytics/2026-09-18-local-player-double-tap.md
vrubelroman a89f972afa 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).
2026-09-25 11:06:33 +00:00

88 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Перемотка ±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 не трогаем.