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

@ -183,7 +183,22 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.video-page { max-width: 1320px; }
.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; }
.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-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; }
@ -277,7 +292,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.video-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; }
.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; }
.manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
.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; }
.video-detail-head { flex-direction: column; gap: 9px; }
.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-panel { padding: 18px; }
.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 (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; }
.view-toggle a { min-height: 44px; }
.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; }
}
@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 type { FeedVideoDto } 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 {
video: FeedVideoDto
}
function Player({ video }: Props) {
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 recheckMutation = useMutation({
@ -21,11 +99,157 @@ function Player({ video }: Props) {
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) {
return (
<>
<div className="player-wrapper">
<video
ref={videoRef}
controls
src={video.local.media_url!}
onError={() => {
@ -33,8 +257,16 @@ function Player({ video }: Props) {
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 className="video-page-source">Источник: локальная копия</div>
</>
)
}
@ -42,15 +274,22 @@ function Player({ video }: Props) {
return (
<>
<div className="player-wrapper">
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
{ytApiStatus === 'timeout' ? (
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<div ref={ytHostRef} className="yt-player-host" />
)}
</div>
<div className="video-page-source">
{localFailed ? 'Локальная копия недоступна. Переключено на YouTube.' : 'Источник: YouTube'}
<div className="video-page-meta-row">
<div className="video-page-source">
{localFailed ? 'Локальная копия недоступна. Переключено на YouTube.' : 'Источник: YouTube'}
</div>
{seekButtons}
</div>
</>
)