- '−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).
30 KiB
Перемотка ±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): viewportwidth=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— без изменений.
Решения (зафиксированы)
- 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 в компоненте. - Контрол-бар не считается. Тап игнорируется, если
clientY > rect.bottom − 80(константа-запас под высоту нативных контролов 40–80 px в разных браузерах/fullscreen). Без этого тапы по кнопкам контролов (ретаргет на host) давали бы ложные перемотки. - Seek:
video.currentTime = clamp(currentTime ± 10, 0, duration). При недогруженных метаданныхdurationможет быть NaN — тогда гарантируем только нижнюю границу 0 (верхнюю браузер клампит сам). После успешного срабатывания сбрасывать состояние «первого тапа», чтобы третий быстрый тап не вызвал повторный seek. - Индикатор: локальный state
{ delta: +10 | −10, side: 'left' | 'right', id: number }(id — счётчик для рестарта анимации при повторных тапах). Текст «+10 сек»/«−10 сек» (минус типографский «−»). Позиция — на стороне тапа (CSS-классы.left/.right, ~25–30% от края, центр по вертикали).pointer-events: none— не мешает ни видео, ни контролам. Таймер ~750 мс в ref: по истечении индикатор убирается из DOM, при новом тапе таймер перезапускается. Анимация — появление + затухание через@keyframes. touch-action: manipulationна видео (правило.player-wrapper video): отключает double-tap-zoom браузера на всей поверхности плеера (включая область нативных контролов — они в shadow DOM video), сохраняет панорамирование (скролл страницы свайпом по видео) и pinch-zoom. Viewport вindex.htmlне меняем. Для iframe-ветки правило безвредно (на iframetouch-actionне вешаем, поведение YouTube не трогаем).- Одиночный тап и контролы не ломаются: обработчики не вызывают
preventDefault/stopPropagationпо пути, не мешающему нативному toggle play/pause и кликам по контролам; seek происходит только при распознанном двойном тапе. - Десктоп (мышь): фильтр по
pointerTypeНЕ добавляем — двойной клик мышью тоже даёт ±10 сек. Обоснование: это в точности повторяет поведение YouTube на десктопе (двойной клик по видео = перемотка); нативный<video>не имеет собственного действия на double-click, конфликта нет; Y-защита отсекает клики по контролам; одиночный клик (pause/play) не затрагивается, т.к. seek только на втором клике в окне 300 мс. Отсечение мыши усложнило бы код без выигрыша. - YouTube-ветка: перемотка только кнопками, двойной тап не трогаем. Двойной тап в embed — встроенное поведение плеера YouTube, кросс-доменные события недоступны, оверлей = костыль. Надёжный штатный путь (в т.ч. для iPad, где встроенный двойной тап нестабилен) — кнопки «−10 сек»/«+10 сек» под плеером, работающие через IFrame Player API (п. 10–12).
- Cleanup:
useEffectприuseLocal === trueвешает слушатели наvideoRef.current; возвращаемая cleanup-функция снимает их, чистит таймер индикатора и сбрасывает состояние последнего тапа. Хуки объявляются до раннего return (структура компонента сейчас не позволяет хуки послеif (useLocal)). При ветке iframe effect no-op (video отсутствует), при ремоунте поkeyвсё подчищается. - 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наивно. - Фолбэк без 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. В фолбэке поведение не хуже текущего. - Кнопки перемотки. Ряд (новый класс, напр.
.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 по аналогии с существующими правилами. - Cleanup YouTube-ветки: при unmount или переключении ветки (local→YouTube при
onError, смена видео поkey) —player.destroy(), снятие таймеров загрузки/фолбэка, сброс состояния. Ремоунт поkey={video.youtube_video_id}вVideoPageпересоздаёт плеер под новое видео — ожидаемо и корректно. - Типизация 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.
Критерии приёмки
- На мобильном (тач): двойной тап по правой половине локального видео → +10 сек, по левой → −10 сек; у краёв диапазона значение клампится в [0, duration]; каждый сработавший жест показывает индикатор «+10 сек»/«−10 сек» на соответствующей стороне, исчезающий через ~0,7–0,9 с.
- Одиночный тап по видео — play/pause как раньше; тапы по нативным контролам (play, полоса перемотки, громкость, fullscreen) не вызывают перемотку и работают как раньше.
- Браузер не приближает плеер double-tap-zoom (touch-action), при этом pinch-zoom и скролл страницы работают.
- Десктоп не сломан: одиночный клик — play/pause; двойной клик — ±10 сек (осознанное решение, п. 7).
- Кнопки «−10 сек»/«+10 сек» под плеером есть на обеих ветках: локальная — перемотка с клампингом [0, duration]; YouTube — через
seekTo(getCurrentTime() ± 10, true), работает в том числе в браузере iPad. - YouTube-ветка работает через IFrame Player API (nocookie-хост, playsinline): воспроизведение, встроенные контролы, fullscreen — как раньше; двойной тап по embed не перехватывается, никаких оверлеев поверх iframe.
- Фолбэк: при недоступности API за ~5–8 с рендерится plain-iframe как сейчас, кнопки скрыты/disabled; страница и подпись источника не ломаются, ошибок в консоли нет (кроме ожидаемых сетевых к youtube.com).
- Кнопки доступны (aria-label), стиль
button-secondary, не ломают подпись источника и DownloadButton на странице видео. - Нет утечек: при размонтировании (уход со страницы видео, смена видео по key, переход local→YouTube при
onError) сняты pointer-слушатели, таймер индикатора и таймер фолбэка;YT.Playerуничтожен черезdestroy(). npm run lintиnpm run buildвfrontend/чистые.- Backend не тронут:
git status— изменения только вfrontend/иanalytics/;pytestне требуется. - Деплой по правилу:
docker compose up -d --build,curl http://localhost:8080/api/health→ OK.
План
Player.tsx, локальная ветка: добавитьvideoRef,lastTapRef({ time, x, y } | null),tapIndicatorstate ({ 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.Player.tsx, кнопки и API: хук-структура до раннего return (useLocal):ytHostRef,playerRef, state API ('loading' | 'ready' | 'timeout'), загрузчик скрипта IFrame API (промис, идемпотентный, только для YouTube-ветки).useEffectYouTube-ветки: загрузка API →new YT.Player(ytHostRef.current, { videoId, host: youtube-nocookie, playerVars: { playsinline: 1 }, events: { onReady } }); таймаут ~5–8 с → statetimeout(рендер plain-iframe как сейчас); cleanup —destroy(), сброс таймера. Рендер YouTube-ветки: хост-div (до ready — пустой/спиннер не обязателен) или фолбэк-iframe; ряд.video-page-actionsс двумя кнопками на обеих ветках (YouTube:disabledдоonReady, скрыты/disabled в фолбэке; локальная: активны, seek черезvideoRef).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.- Типизация:
declare globalдляwindow.YT/onYouTubeIframeAPIReady(минимальные типы,import type). - Проверки:
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 не трогаем.