340 lines
9.2 KiB
TypeScript
340 lines
9.2 KiB
TypeScript
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<string, number>();
|
|
const resolvedPosterCache = new Map<string, { resolvedPoster: string; resolvedAt: number }>();
|
|
|
|
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<HTMLImageElement | HTMLVideoElement>;
|
|
videoProps?: Omit<
|
|
React.VideoHTMLAttributes<HTMLVideoElement>,
|
|
'src' | 'poster' | 'className' | 'onClick'
|
|
>;
|
|
}
|
|
|
|
function renderPreviewWithOverlay(
|
|
media: React.ReactNode,
|
|
showPlayOverlay: boolean
|
|
): React.ReactElement {
|
|
return (
|
|
<div className="video-poster-preview">
|
|
{media}
|
|
{showPlayOverlay && (
|
|
<div className="video-poster-preview__play-overlay" aria-hidden="true">
|
|
<PlayCircleIcon size="32px" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
|
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<HTMLVideoElement>(null);
|
|
const activatedByClickRef = useRef(false);
|
|
const retryTimerRef = useRef<number | null>(null);
|
|
const [resolvedPoster, setResolvedPoster] = useState<string | null>(() => 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<HTMLImageElement> = (event) => {
|
|
if (activateVideoOnClick) {
|
|
activatedByClickRef.current = true;
|
|
setShowVideo(true);
|
|
}
|
|
onClick?.(event);
|
|
};
|
|
|
|
const handleVideoClick: React.MouseEventHandler<HTMLVideoElement> = (event) => {
|
|
onClick?.(event);
|
|
};
|
|
const showPlayOverlay = activateVideoOnClick && !showVideo;
|
|
|
|
if (!showVideo && resolvedPoster) {
|
|
return renderPreviewWithOverlay(
|
|
<img
|
|
src={resolvedPoster}
|
|
alt={alt}
|
|
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
|
loading={imageLoading}
|
|
decoding="async"
|
|
draggable={false}
|
|
referrerPolicy="no-referrer"
|
|
onClick={handlePosterClick}
|
|
/>,
|
|
showPlayOverlay
|
|
);
|
|
}
|
|
|
|
if (!showVideo && !resolvedPoster) {
|
|
return renderPreviewWithOverlay(
|
|
<div
|
|
className={`video-poster-preview__placeholder${className ? ` ${className}` : ''}`}
|
|
aria-hidden="true"
|
|
onClick={
|
|
activateVideoOnClick
|
|
? () => {
|
|
activatedByClickRef.current = true;
|
|
setShowVideo(true);
|
|
}
|
|
: undefined
|
|
}
|
|
/>,
|
|
showPlayOverlay
|
|
);
|
|
}
|
|
|
|
return renderPreviewWithOverlay(
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={resolvedPoster || normalizedPoster}
|
|
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
|
playsInline
|
|
{...videoProps}
|
|
onClick={handleVideoClick}
|
|
/>,
|
|
false
|
|
);
|
|
};
|