From 8dca8a64434b4056ab1f55cbaddd8cdd38c72cb2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 16:00:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A8=B3=E5=AE=9A=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E9=A1=B5=E8=BF=90=E8=A1=8C=E6=80=81=E5=B0=81=E9=9D=A2=E9=81=AE?= =?UTF-8?q?=E7=BD=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../RpgEntryHomeView.recharge.test.tsx | 242 +++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 433 ++++++++++++++++-- src/index.css | 6 +- src/index.test.ts | 15 + 6 files changed, 672 insertions(+), 34 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4fb35137..2b746a75 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐 + +- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。 +- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。推荐 rail 切换完成后归零不能走反向过渡动画。 +- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。 +- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-06 小程序微信绑定展示使用原生昵称组件 - 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3292a9ac..f1a99151 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -176,7 +176,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。ready 前不展示“加载中”文案,也不得把未准备好的运行态直接暴露给用户。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 684729f4..8147756e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -41,6 +41,7 @@ import { const { mockQrCodeToDataUrl, mockRedirectToPaymentUrl, + mockRequestJson, mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, @@ -128,6 +129,7 @@ const { return { mockQrCodeToDataUrl: qrCodeToDataUrl, mockRedirectToPaymentUrl: redirectToPaymentUrl, + mockRequestJson: vi.fn(), mockBuildReferralCenter: buildReferralCenter, mockBuildTaskCenter: buildTaskCenter, mockGetRpgProfileReferralInviteCenter: vi.fn(async () => @@ -349,6 +351,10 @@ const { })); vi.mock('../../services/apiClient', () => ({ + BACKGROUND_AUTH_REQUEST_OPTIONS: { + authImpact: 'background', + }, + requestJson: mockRequestJson, refreshStoredAccessToken: mockRefreshStoredAccessToken, })); @@ -3906,6 +3912,234 @@ test('mobile recommend startup keeps cover visible without loading copy', () => expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); +test('mobile recommend preloads every fetched work cover', () => { + const preloadedSrcs: string[] = []; + mockRequestJson.mockResolvedValue({ + read: { + objectKey: 'generated-recommend/current.png', + signedUrl: 'https://signed.example.com/current-cover.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }); + class MockPreloadImage { + decoding = 'auto'; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(value: string) { + preloadedSrcs.push(value); + } + } + vi.stubGlobal('Image', MockPreloadImage); + + const firstEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-feed-1', + profileId: 'puzzle-profile-feed-1', + ownerUserId: 'user-feed-1', + publicWorkCode: 'PZ-FEED1', + worldName: '当前拼图', + coverImageSrc: '/generated-recommend/current.png', + } satisfies PlatformPublicGalleryCard; + const secondEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-feed-2', + profileId: 'puzzle-profile-feed-2', + ownerUserId: 'user-feed-2', + publicWorkCode: 'PZ-FEED2', + worldName: '下一拼图', + coverImageSrc: 'next-cover.png', + } satisfies PlatformPublicGalleryCard; + const thirdEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-feed-3', + profileId: 'puzzle-profile-feed-3', + ownerUserId: 'user-feed-3', + publicWorkCode: 'PZ-FEED3', + worldName: '上一拼图', + coverImageSrc: 'previous-cover.png', + } satisfies PlatformPublicGalleryCard; + + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [firstEntry, secondEntry, thirdEntry], + activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1', + }); + + expect(preloadedSrcs).toEqual( + expect.arrayContaining([ + 'next-cover.png', + 'previous-cover.png', + '/creation-type-references/puzzle.webp', + ]), + ); + expect(preloadedSrcs).not.toContain('/generated-recommend/current.png'); + return waitFor(() => { + expect(preloadedSrcs).toContain('https://signed.example.com/current-cover.png'); + }); +}); + +test('mobile recommend runtime cover keeps the labeled work card visual', () => { + const carouselEntry = buildCarouselPuzzleEntry( + 'recommend-card-cover', + '轮播拼图', + 'card-cover', + ); + + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [carouselEntry], + activeRecommendEntryKey: + 'puzzle:user-2:puzzle-profile-recommend-card-cover', + isStartingRecommendEntry: true, + recommendRuntimeContent: null, + }); + + const cover = document.querySelector('.platform-recommend-runtime-cover'); + const coverImage = cover?.querySelector('img'); + + expect(coverImage?.getAttribute('src')).toBe('card-cover-1.png'); + expect(coverImage?.getAttribute('src')).not.toBe('card-cover-fallback.png'); + expect(cover?.querySelector('.platform-recommend-runtime-preview')).toBeTruthy(); + expect(cover?.textContent).toContain('拼图'); + expect(cover?.textContent).toContain('轮播拼图'); +}); + +test('mobile recommend runtime cover does not swap to a late signed cover', async () => { + const preloadImages: Array<{ + srcValue: string; + onload: (() => void) | null; + }> = []; + mockRequestJson.mockResolvedValue({ + read: { + objectKey: 'generated-recommend/late-current.png', + signedUrl: 'https://signed.example.com/late-current-cover.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }); + class MockPreloadImage { + decoding = 'auto'; + complete = false; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + srcValue = ''; + + set src(value: string) { + this.srcValue = value; + preloadImages.push(this); + } + } + vi.stubGlobal('Image', MockPreloadImage); + + const generatedCoverEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-late-cover', + profileId: 'puzzle-profile-late-cover', + ownerUserId: 'user-late-cover', + publicWorkCode: 'PZ-LATE1', + worldName: '迟到封面拼图', + coverImageSrc: '/generated-recommend/late-current.png', + } satisfies PlatformPublicGalleryCard; + + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [generatedCoverEntry], + activeRecommendEntryKey: 'puzzle:user-late-cover:puzzle-profile-late-cover', + isStartingRecommendEntry: true, + recommendRuntimeContent: null, + }); + + const cover = document.querySelector('.platform-recommend-runtime-cover'); + const coverImage = cover?.querySelector('img'); + expect(coverImage?.getAttribute('src')).toBe( + '/creation-type-references/puzzle.webp', + ); + + await waitFor(() => { + expect( + preloadImages.some( + (image) => + image.srcValue === + 'https://signed.example.com/late-current-cover.png', + ), + ).toBe(true); + }); + + act(() => { + preloadImages + .filter( + (image) => + image.srcValue === + 'https://signed.example.com/late-current-cover.png', + ) + .forEach((image) => image.onload?.()); + }); + + await waitFor(() => expect(mockRequestJson).toHaveBeenCalled()); + expect( + document + .querySelector('.platform-recommend-runtime-cover img') + ?.getAttribute('src'), + ).toBe('/creation-type-references/puzzle.webp'); + expect( + Array.from( + document.querySelectorAll('.platform-recommend-runtime-cover img'), + ).some( + (image) => + image.getAttribute('src') === + 'https://signed.example.com/late-current-cover.png', + ), + ).toBe(false); +}); + +test('mobile recommend cover waits until runtime images are ready', async () => { + vi.useFakeTimers(); + const animationCallbacks: FrameRequestCallback[] = []; + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + writable: true, + value: vi.fn((callback: FrameRequestCallback) => { + animationCallbacks.push(callback); + return animationCallbacks.length; + }), + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + writable: true, + value: vi.fn(), + }); + + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + isRecommendRuntimeReady: true, + recommendRuntimeContent: ( + runtime sprite + ), + }); + + act(() => { + animationCallbacks.splice(0).forEach((callback) => callback(16)); + }); + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).not.toContain('platform-recommend-runtime-cover--hidden'); + + fireEvent.load(screen.getByAltText('runtime sprite')); + act(() => { + animationCallbacks.splice(0).forEach((callback) => callback(32)); + }); + + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).not.toContain('platform-recommend-runtime-cover--hidden'); + + await act(async () => { + vi.advanceTimersByTime(520); + }); + + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).toContain('platform-recommend-runtime-cover--hidden'); +}); + test('mobile recommend keeps runtime visual stable when active entry changes', async () => { const animationCallbacks: FrameRequestCallback[] = []; Object.defineProperty(window, 'requestAnimationFrame', { @@ -4119,7 +4353,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); expect( - document.querySelectorAll('.platform-recommend-runtime-preview'), + document.querySelectorAll( + '.platform-recommend-runtime-preview:not(.platform-recommend-runtime-preview--cover)', + ), ).toHaveLength(2); expect( document.querySelectorAll('.platform-recommend-swipe-card'), @@ -4170,6 +4406,10 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled(); + expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)'); + expect(rail?.className).toContain( + 'platform-recommend-swipe-rail--resetting', + ); vi.useRealTimers(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index a6a3f594..fa8220e2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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; + +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(); + + 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 + >(() => 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(); + 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 { + 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 (