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

30 KiB
Raw Blame History

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