Initial TrueGrowth source import
This commit is contained in:
339
packages/drawnix/src/components/shared/VideoPosterPreview.tsx
Normal file
339
packages/drawnix/src/components/shared/VideoPosterPreview.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user