Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View 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
);
};