fix: 稳定推荐页运行态封面遮罩

This commit is contained in:
2026-06-07 16:00:29 +08:00
parent c810e255a5
commit 8dca8a6443
6 changed files with 672 additions and 34 deletions

View File

@@ -37,6 +37,7 @@ import {
type CSSProperties,
type PointerEvent,
type ReactNode,
type RefObject,
Suspense,
useCallback,
useEffect,
@@ -79,6 +80,10 @@ import type {
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { refreshStoredAccessToken } from '../../services/apiClient';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -261,6 +266,260 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
function resolveRecommendDisplayCoverImage(
imageSrc: string,
fallbackSrc: string,
resolvedCoverUrls?: RecommendResolvedCoverUrlMap,
) {
const normalizedImageSrc = imageSrc.trim();
const normalizedFallbackSrc = fallbackSrc.trim();
if (!normalizedImageSrc) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
const resolvedImageSrc = resolvedCoverUrls?.get(normalizedImageSrc);
if (resolvedImageSrc) {
return resolvedImageSrc;
}
if (isGeneratedLegacyPath(normalizedImageSrc)) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
return normalizedImageSrc;
}
function resolveRecommendCardCoverImage(entry: PlatformPublicGalleryCard) {
const cardCoverSlide = resolvePlatformWorldCoverSlides(entry)[0] ?? null;
return (
cardCoverSlide?.imageSrc.trim() || resolvePlatformWorldCoverImage(entry)
);
}
function collectRecommendCoverPreloadUrls(entries: PlatformPublicGalleryCard[]) {
const urls = new Set<string>();
entries.forEach((entry) => {
resolvePlatformWorldCoverSlides(entry).forEach((slide) => {
const slideImageSrc = slide.imageSrc.trim();
if (slideImageSrc) {
urls.add(slideImageSrc);
}
});
[
resolveRecommendCardCoverImage(entry),
resolvePlatformWorldCoverImage(entry),
resolvePlatformWorldFallbackCoverImage(entry),
]
.map((url) => url.trim())
.filter(Boolean)
.forEach((url) => urls.add(url));
});
return [...urls];
}
function useResolvedRecommendCoverImages(
entries: PlatformPublicGalleryCard[],
): RecommendResolvedCoverUrlMap {
const preloadUrls = useMemo(
() => collectRecommendCoverPreloadUrls(entries),
[entries],
);
const preloadKey = preloadUrls.join('\n');
const [resolvedCoverUrls, setResolvedCoverUrls] = useState<
Map<string, string>
>(() => new Map());
useEffect(() => {
let cancelled = false;
const cleanupCallbacks: Array<() => void> = [];
const preloadCoverImage = (
imageSrc: string,
onLoaded?: (loadedImageSrc: string) => void,
) => {
if (!imageSrc || typeof Image === 'undefined') {
onLoaded?.(imageSrc);
return;
}
const image = new Image();
const cleanupImage = () => {
image.onload = null;
image.onerror = null;
};
const finishImageLoad = () => {
if (cancelled) {
return;
}
cleanupImage();
onLoaded?.(imageSrc);
};
const finishImageError = () => {
if (cancelled) {
return;
}
cleanupImage();
};
image.decoding = 'async';
image.onload = finishImageLoad;
image.onerror = finishImageError;
image.src = imageSrc;
if (image.complete) {
finishImageLoad();
}
cleanupCallbacks.push(cleanupImage);
};
setResolvedCoverUrls((currentUrls) => {
const nextUrls = new Map<string, string>();
preloadUrls.forEach((url) => {
const cachedUrl = currentUrls.get(url);
if (cachedUrl) {
nextUrls.set(url, cachedUrl);
return;
}
if (!isGeneratedLegacyPath(url)) {
nextUrls.set(url, url);
}
});
return nextUrls;
});
preloadUrls.forEach((url) => {
if (!isGeneratedLegacyPath(url)) {
preloadCoverImage(url);
return;
}
void resolveAssetReadUrl(url)
.then((resolvedUrl) => {
if (cancelled || !resolvedUrl) {
return;
}
preloadCoverImage(resolvedUrl, (loadedUrl) => {
if (cancelled) {
return;
}
setResolvedCoverUrls((currentUrls) => {
if (currentUrls.get(url) === loadedUrl) {
return currentUrls;
}
const nextUrls = new Map(currentUrls);
nextUrls.set(url, loadedUrl);
return nextUrls;
});
});
})
.catch(() => undefined);
});
return () => {
cancelled = true;
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
};
}, [preloadKey, preloadUrls]);
return resolvedCoverUrls;
}
function scheduleRecommendRuntimeReady(
signal: AbortSignal,
onReady: (value: boolean) => void,
) {
if (signal.aborted) {
return null;
}
return window.requestAnimationFrame(() => {
if (!signal.aborted) {
onReady(true);
}
});
}
function readyRecommendRuntime(
root: HTMLElement | null,
signal: AbortSignal,
): Promise<boolean> {
if (!root || signal.aborted) {
return Promise.resolve(false);
}
const pendingImages = Array.from(root.querySelectorAll('img')).filter(
(image) => !image.complete,
);
return new Promise((resolve) => {
let animationFrameId: number | null = null;
let settled = false;
const cleanupCallbacks: Array<() => void> = [];
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resolve(value);
};
const abort = () => finish(false);
signal.addEventListener('abort', abort, { once: true });
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
if (pendingImages.length === 0) {
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
if (animationFrameId === null) {
finish(false);
}
return;
}
let remaining = pendingImages.length;
const markImageReady = () => {
remaining -= 1;
if (remaining > 0) {
return;
}
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
if (animationFrameId === null) {
finish(false);
}
};
pendingImages.forEach((image) => {
const cleanupImageListeners = () => {
image.removeEventListener('load', markImageReady);
image.removeEventListener('error', markImageReady);
};
image.addEventListener('load', markImageReady, { once: true });
image.addEventListener('error', markImageReady, { once: true });
cleanupCallbacks.push(cleanupImageListeners);
if (image.complete) {
cleanupImageListeners();
markImageReady();
}
});
});
}
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
@@ -928,17 +1187,62 @@ function CreationLibraryCard({
function RecommendRuntimePreviewCard({
entry,
position,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
position: 'previous' | 'next';
position?: 'previous' | 'next' | 'cover';
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const rawCoverImage = resolveRecommendCardCoverImage(entry);
const rawFallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const resolvedCoverImage = resolveRecommendDisplayCoverImage(
rawCoverImage,
rawFallbackCoverImage,
resolvedCoverUrls,
);
const fallbackCoverImage =
resolvedCoverUrls?.get(rawFallbackCoverImage) ?? rawFallbackCoverImage;
const previewKey = `${buildPublicGalleryCardKey(entry)}:${position ?? 'preview'}`;
const shouldLockCoverImage = position === 'cover';
const [lockedCoverImage, setLockedCoverImage] = useState({
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
});
useEffect(() => {
setLockedCoverImage((currentValue) => {
if (shouldLockCoverImage) {
return currentValue;
}
return {
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
};
});
}, [
fallbackCoverImage,
previewKey,
resolvedCoverImage,
shouldLockCoverImage,
]);
const coverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: resolvedCoverImage;
const displayFallbackCoverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: fallbackCoverImage;
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const previewClassName = `platform-recommend-runtime-preview ${
position === 'cover' ? 'platform-recommend-runtime-preview--cover' : ''
}`;
return (
<div
className="platform-recommend-runtime-preview"
className={previewClassName}
aria-hidden="true"
data-preview-position={position}
>
@@ -946,6 +1250,7 @@ function RecommendRuntimePreviewCard({
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={displayFallbackCoverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -966,43 +1271,48 @@ function RecommendRuntimePreviewCard({
function RecommendRuntimeCover({
entry,
className = '',
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
className?: string;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
return (
<div
className={`platform-recommend-runtime-cover ${className}`}
aria-hidden="true"
>
{coverImage || fallbackCoverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackCoverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
<RecommendRuntimePreviewCard
key={buildPublicGalleryCardKey(entry)}
entry={entry}
position="cover"
resolvedCoverUrls={resolvedCoverUrls}
/>
</div>
);
}
function RecommendRuntimeMountedProbe({
onMounted,
function RecommendRuntimeReadyProbe({
rootRef,
onReady,
}: {
onMounted: () => void;
rootRef: RefObject<HTMLDivElement | null>;
onReady: () => void;
}) {
useEffect(() => {
const animationFrameId = window.requestAnimationFrame(onMounted);
return () => window.cancelAnimationFrame(animationFrameId);
}, [onMounted]);
const abortController = new AbortController();
void readyRecommendRuntime(
rootRef.current,
abortController.signal,
).then((ready) => {
if (ready) {
onReady();
}
});
return () => abortController.abort();
}, [onReady, rootRef]);
return null;
}
@@ -1012,15 +1322,55 @@ function RecommendRuntimeVisual({
runtimeContent,
isStarting,
isRuntimeReady,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
runtimeContent?: ReactNode;
isStarting: boolean;
isRuntimeReady: boolean;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
const [isCoverMinVisible, setIsCoverMinVisible] = useState(true);
const activeEntryKey = buildPublicGalleryCardKey(entry);
const previousEntryKeyRef = useRef(activeEntryKey);
const runtimeVisibilityRef = useRef({
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted: false,
isRuntimeReady,
isStarting,
});
const runtimeViewportRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
runtimeVisibilityRef.current = {
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted,
isRuntimeReady,
isStarting,
};
}, [isRuntimeMounted, isRuntimeReady, isStarting, runtimeContent]);
useEffect(() => {
const currentRuntimeVisibility = runtimeVisibilityRef.current;
if (
previousEntryKeyRef.current !== activeEntryKey &&
currentRuntimeVisibility.hasRuntimeContent &&
currentRuntimeVisibility.isRuntimeMounted &&
currentRuntimeVisibility.isRuntimeReady &&
!currentRuntimeVisibility.isStarting
) {
setIsCoverMinVisible(false);
return undefined;
}
setIsCoverMinVisible(true);
const timeoutId = window.setTimeout(() => {
setIsCoverMinVisible(false);
}, RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS);
return () => window.clearTimeout(timeoutId);
}, [activeEntryKey]);
useEffect(() => {
if (previousEntryKeyRef.current === activeEntryKey) {
@@ -1037,33 +1387,40 @@ function RecommendRuntimeVisual({
});
}, [activeEntryKey, isRuntimeReady, isStarting]);
const handleRuntimeMounted = useCallback(() => {
const handleRuntimeReady = useCallback(() => {
if (!isStarting && isRuntimeReady) {
setIsRuntimeMounted(true);
}
}, [isRuntimeReady, isStarting]);
const shouldShowCover =
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
isCoverMinVisible ||
!runtimeContent ||
isStarting ||
!isRuntimeReady ||
!isRuntimeMounted;
return (
<div className="platform-recommend-runtime-visual">
{runtimeContent ? (
<Suspense fallback={null}>
<div
ref={runtimeViewportRef}
className="platform-recommend-runtime-viewport"
aria-hidden={shouldShowCover}
>
{runtimeContent}
<RecommendRuntimeReadyProbe
key={activeEntryKey}
rootRef={runtimeViewportRef}
onReady={handleRuntimeReady}
/>
</div>
<RecommendRuntimeMountedProbe
key={activeEntryKey}
onMounted={handleRuntimeMounted}
/>
</Suspense>
) : null}
<RecommendRuntimeCover
entry={entry}
resolvedCoverUrls={resolvedCoverUrls}
className={
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
}
@@ -5424,6 +5781,8 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const resolvedRecommendCoverUrls =
useResolvedRecommendCoverImages(recommendedFeedEntries);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
@@ -5571,6 +5930,8 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [isRecommendDragResetting, setIsRecommendDragResetting] =
useState(false);
const activeRecommendEntryKeyForSelection = activeRecommendEntry
? buildPublicGalleryCardKey(activeRecommendEntry)
: null;
@@ -5587,6 +5948,7 @@ export function RpgEntryHomeView({
}
setRecommendDragCommitDirection(direction);
setIsRecommendDragResetting(false);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
@@ -5599,8 +5961,12 @@ export function RpgEntryHomeView({
} else {
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
}
setIsRecommendDragResetting(true);
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
window.requestAnimationFrame(() => {
setIsRecommendDragResetting(false);
});
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
@@ -5689,7 +6055,9 @@ export function RpgEntryHomeView({
} satisfies CSSProperties;
const recommendRailClassName = recommendDragCommitDirection
? 'platform-recommend-swipe-rail--committing'
: recommendDragOffsetY === 0
: isRecommendDragResetting
? 'platform-recommend-swipe-rail--resetting'
: recommendDragOffsetY === 0
? 'platform-recommend-swipe-rail--settled'
: 'platform-recommend-swipe-rail--dragging';
const selectNextRecommendEntry = useCallback(() => {
@@ -5826,6 +6194,7 @@ export function RpgEntryHomeView({
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>
@@ -5848,6 +6217,7 @@ export function RpgEntryHomeView({
runtimeContent={recommendRuntimeContent}
isStarting={isStartingRecommendEntry}
isRuntimeReady={isRecommendRuntimeReady}
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
onDragPointerDown={beginRecommendDrag}
@@ -5875,6 +6245,7 @@ export function RpgEntryHomeView({
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>