import React, { useEffect, useMemo, useRef, useState } from 'react'; import { PlayCircleIcon } from 'tdesign-icons-react'; import { useThumbnailUrl } from '../../hooks/useThumbnailUrl'; import './VideoPosterPreview.scss'; const THUMBNAIL_PLACEHOLDER_SIZE = 1; const MAX_THUMBNAIL_RETRIES = 4; const FAILED_POSTER_TTL_MS = 10 * 60 * 1000; const RESOLVED_POSTER_TTL_MS = 10 * 60 * 1000; const failedPosterCache = new Map(); const resolvedPosterCache = new Map(); function cleanupFailedPosterCache(now: number): void { failedPosterCache.forEach((failedAt, url) => { if (now - failedAt > FAILED_POSTER_TTL_MS) { failedPosterCache.delete(url); } }); } function markPosterAsFailed(url: string): void { const now = Date.now(); cleanupFailedPosterCache(now); failedPosterCache.set(url, now); } function hasRecentFailedPoster(url: string): boolean { const now = Date.now(); cleanupFailedPosterCache(now); const failedAt = failedPosterCache.get(url); return typeof failedAt === 'number' && now - failedAt <= FAILED_POSTER_TTL_MS; } function cleanupResolvedPosterCache(now: number): void { resolvedPosterCache.forEach((entry, url) => { if (now - entry.resolvedAt > RESOLVED_POSTER_TTL_MS) { resolvedPosterCache.delete(url); } }); } function cacheResolvedPoster(url: string, resolvedPoster: string): void { const now = Date.now(); cleanupResolvedPosterCache(now); resolvedPosterCache.set(url, { resolvedPoster, resolvedAt: now }); } function getCachedResolvedPoster(url: string | undefined): string | null { if (!url) { return null; } const now = Date.now(); cleanupResolvedPosterCache(now); const entry = resolvedPosterCache.get(url); if (!entry) { return null; } return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS ? entry.resolvedPoster : null; } function withPosterRetryParam(url: string, attempt: number): string { if (attempt <= 0 || !url) { return url; } try { const resolved = new URL(url, window.location.origin); resolved.searchParams.set('_poster_retry', String(attempt)); return resolved.toString(); } catch { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}_poster_retry=${attempt}`; } } export interface VideoPosterPreviewProps { src: string; poster?: string; alt?: string; className?: string; thumbnailSize?: 'small' | 'large'; imageLoading?: 'lazy' | 'eager'; activateVideoOnClick?: boolean; playOnActivate?: boolean; onClick?: React.MouseEventHandler; videoProps?: Omit< React.VideoHTMLAttributes, 'src' | 'poster' | 'className' | 'onClick' >; } function renderPreviewWithOverlay( media: React.ReactNode, showPlayOverlay: boolean ): React.ReactElement { return (
{media} {showPlayOverlay && ( )}
); } export const VideoPosterPreview: React.FC = ({ src, poster, alt = '', className, thumbnailSize = 'small', imageLoading = 'lazy', activateVideoOnClick = false, playOnActivate = false, onClick, videoProps, }) => { const normalizedPoster = useMemo( () => (typeof poster === 'string' && poster.trim() ? poster.trim() : undefined), [poster] ); const shouldIgnoreExplicitPoster = useMemo( () => (normalizedPoster ? hasRecentFailedPoster(normalizedPoster) : false), [normalizedPoster] ); const generatedPoster = useThumbnailUrl( src, 'video', thumbnailSize ); const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(shouldIgnoreExplicitPoster); const posterCandidate = preferGeneratedPoster ? generatedPoster : (normalizedPoster || generatedPoster); const hasExplicitPoster = Boolean(normalizedPoster && !preferGeneratedPoster); const canRetryGeneratedPoster = Boolean( posterCandidate && posterCandidate === generatedPoster && posterCandidate !== src ); const videoRef = useRef(null); const activatedByClickRef = useRef(false); const retryTimerRef = useRef(null); const [resolvedPoster, setResolvedPoster] = useState(() => getCachedResolvedPoster(posterCandidate)); const [retryCount, setRetryCount] = useState(0); const [showVideo, setShowVideo] = useState(false); const shouldRequireExplicitActivation = activateVideoOnClick; useEffect(() => { setPreferGeneratedPoster(shouldIgnoreExplicitPoster); }, [shouldIgnoreExplicitPoster]); useEffect(() => { setResolvedPoster(getCachedResolvedPoster(posterCandidate)); setRetryCount(0); setShowVideo(false); activatedByClickRef.current = false; if (retryTimerRef.current !== null) { window.clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } }, [src, posterCandidate, activateVideoOnClick, generatedPoster, normalizedPoster, thumbnailSize]); useEffect(() => { if (showVideo || (resolvedPoster && resolvedPoster === posterCandidate)) { return; } if (!posterCandidate) { if (!shouldRequireExplicitActivation) { setShowVideo(true); } return; } const imageUrl = hasExplicitPoster ? posterCandidate : withPosterRetryParam(posterCandidate, retryCount); let cancelled = false; const probeImage = new Image(); const scheduleRetry = () => { if (!canRetryGeneratedPoster || retryCount >= MAX_THUMBNAIL_RETRIES) { if (!shouldRequireExplicitActivation) { setShowVideo(true); } return; } retryTimerRef.current = window.setTimeout(() => { setRetryCount((value) => value + 1); }, 180 + retryCount * 220); }; probeImage.onload = () => { if (cancelled) { return; } if ( hasExplicitPoster || probeImage.naturalWidth > THUMBNAIL_PLACEHOLDER_SIZE || probeImage.naturalHeight > THUMBNAIL_PLACEHOLDER_SIZE ) { cacheResolvedPoster(posterCandidate, posterCandidate); setResolvedPoster(posterCandidate); return; } scheduleRetry(); }; probeImage.onerror = () => { if (cancelled) { return; } if (hasExplicitPoster && normalizedPoster) { markPosterAsFailed(normalizedPoster); setPreferGeneratedPoster(true); setResolvedPoster(null); setRetryCount(0); return; } scheduleRetry(); }; probeImage.src = imageUrl; return () => { cancelled = true; probeImage.onload = null; probeImage.onerror = null; if (retryTimerRef.current !== null) { window.clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } }; }, [ src, posterCandidate, retryCount, hasExplicitPoster, canRetryGeneratedPoster, showVideo, normalizedPoster, resolvedPoster, shouldRequireExplicitActivation, ]); useEffect(() => { if (!showVideo || !playOnActivate || !activatedByClickRef.current) { return; } activatedByClickRef.current = false; videoRef.current?.play().catch(() => { // 浏览器策略可能阻止自动播放,保留 controls 让用户继续点播 }); }, [showVideo, playOnActivate]); useEffect(() => { return () => { const video = videoRef.current; if (!video) { return; } try { video.pause(); video.removeAttribute('src'); video.load(); } catch { // 忽略释放失败 } }; }, []); const handlePosterClick: React.MouseEventHandler = (event) => { if (activateVideoOnClick) { activatedByClickRef.current = true; setShowVideo(true); } onClick?.(event); }; const handleVideoClick: React.MouseEventHandler = (event) => { onClick?.(event); }; const showPlayOverlay = activateVideoOnClick && !showVideo; if (!showVideo && resolvedPoster) { return renderPreviewWithOverlay( {alt}, showPlayOverlay ); } if (!showVideo && !resolvedPoster) { return renderPreviewWithOverlay(