From 6df34a0eb7ebff83433059c84c9ee4acf37f1853 Mon Sep 17 00:00:00 2001 From: corpi Date: Wed, 18 Mar 2026 20:17:36 +0900 Subject: [PATCH] =?UTF-8?q?fix(space):=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=A7=84=EC=9E=85=20=EB=B3=B5=EC=9B=90=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/media/api/mediaManifestApi.ts | 8 +- src/entities/media/model/resolveMediaAsset.ts | 128 +++++++++++-- src/entities/media/model/useMediaCatalog.ts | 99 +++++++++- .../sound-preset/model/useSoundPlayback.ts | 105 ++++++++++- .../ui/SpaceWorkspaceWidget.tsx | 178 ++++++++++++++++-- 5 files changed, 469 insertions(+), 49 deletions(-) diff --git a/src/entities/media/api/mediaManifestApi.ts b/src/entities/media/api/mediaManifestApi.ts index 785704c..5600f52 100644 --- a/src/entities/media/api/mediaManifestApi.ts +++ b/src/entities/media/api/mediaManifestApi.ts @@ -20,11 +20,15 @@ const resolveMediaManifestUrl = () => { const resolveManifestFetchCache = (): RequestCache => { const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE; - if (configuredCacheMode === 'no-store' || configuredCacheMode === 'force-cache') { + if (configuredCacheMode === 'no-store') { return configuredCacheMode; } - return process.env.NODE_ENV === 'development' ? 'no-store' : 'force-cache'; + if (configuredCacheMode === 'force-cache') { + return process.env.NODE_ENV === 'development' ? 'force-cache' : 'no-store'; + } + + return 'no-store'; }; export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl(); diff --git a/src/entities/media/model/resolveMediaAsset.ts b/src/entities/media/model/resolveMediaAsset.ts index bada8f8..beb7d0e 100644 --- a/src/entities/media/model/resolveMediaAsset.ts +++ b/src/entities/media/model/resolveMediaAsset.ts @@ -15,27 +15,59 @@ const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ?? const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value); -const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => { - if (!value) { - return null; - } - - if (isAbsoluteUrl(value) || value.startsWith('/')) { - return value; - } - - if (!baseUrl) { +const appendCacheBustParam = (value: string, cacheBustKey?: string | null) => { + if (!cacheBustKey) { return value; } try { - return new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); + const url = value.startsWith('/') + ? new URL(value, 'http://localhost') + : new URL(value); + + if (!url.searchParams.has('v')) { + url.searchParams.set('v', cacheBustKey); + } + + if (value.startsWith('/')) { + return `${url.pathname}${url.search}${url.hash}`; + } + + return url.toString(); } catch { return value; } }; +const resolveAssetUrl = ( + value: string | null | undefined, + baseUrl?: string | null, + cacheBustKey?: string | null, +) => { + if (!value) { + return null; + } + + if (isAbsoluteUrl(value) || value.startsWith('/')) { + return appendCacheBustParam(value, cacheBustKey); + } + + if (!baseUrl) { + return appendCacheBustParam(value, cacheBustKey); + } + + try { + return appendCacheBustParam( + new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(), + cacheBustKey, + ); + } catch { + return appendCacheBustParam(value, cacheBustKey); + } +}; + const mergeSceneAssets = (manifest: MediaManifest) => { + const cacheBustKey = manifest.updatedAt || manifest.version; const bySceneId = new Map( DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => { const normalizedSceneId = normalizeSceneAssetId(asset.sceneId); @@ -64,14 +96,15 @@ const mergeSceneAssets = (manifest: MediaManifest) => { return Array.from(bySceneId.values()).map((asset) => ({ ...asset, - cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl) ?? asset.cardUrl, - stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl), - mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl), - hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl), + cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl, cacheBustKey) ?? asset.cardUrl, + stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl, cacheBustKey), + mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl, cacheBustKey), + hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl, cacheBustKey), })); }; const mergeSoundAssets = (manifest: MediaManifest) => { + const cacheBustKey = manifest.updatedAt || manifest.version; const byPresetId = new Map( DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [ asset.presetId, @@ -92,9 +125,9 @@ const mergeSoundAssets = (manifest: MediaManifest) => { return Array.from(byPresetId.values()).map((asset) => ({ ...asset, - previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl), - loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl), - fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl), + previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl, cacheBustKey), + loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl, cacheBustKey), + fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl, cacheBustKey), })); }; @@ -150,6 +183,44 @@ export const getSceneStagePhotoUrl = ( return getSceneCardPhotoUrl(scene, asset); }; +export interface SceneStageLayerSources { + placeholderGradient: string; + underpaintUrl: string | null; + baseImageUrl: string | null; + finalImageUrl: string | null; +} + +export const resolveSceneStageLayerSources = ( + scene: SceneTheme, + asset?: SceneAssetManifestItem | null, + options?: { preferMobile?: boolean; preferHd?: boolean }, +): SceneStageLayerSources => { + const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT; + const baseImageUrl = + (options?.preferMobile ? asset?.mobileStageUrl : null) ?? + asset?.mobileStageUrl ?? + asset?.cardUrl ?? + scene.managedCardPhotoUrl ?? + scene.cardPhotoUrl; + const preferredFinalImageUrl = + (options?.preferHd ? asset?.hdStageUrl : null) ?? + asset?.stageUrl ?? + asset?.hdStageUrl ?? + null; + const finalImageUrl = + preferredFinalImageUrl && preferredFinalImageUrl !== baseImageUrl + ? preferredFinalImageUrl + : null; + const underpaintUrl = baseImageUrl ? null : asset?.blurDataUrl ?? null; + + return { + placeholderGradient, + underpaintUrl, + baseImageUrl, + finalImageUrl, + }; +}; + export const getSceneCardBackgroundStyle = ( scene: SceneTheme, asset?: SceneAssetManifestItem | null, @@ -186,3 +257,24 @@ export const preloadAssetImage = (url: string | null | undefined) => { image.decoding = 'async'; image.src = url; }; + +export const loadAssetImage = (url: string | null | undefined) => { + if (!url || typeof window === 'undefined') { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + const image = new window.Image(); + image.decoding = 'async'; + + image.onload = () => { + resolve(true); + }; + + image.onerror = () => { + resolve(false); + }; + + image.src = url; + }); +}; diff --git a/src/entities/media/model/useMediaCatalog.ts b/src/entities/media/model/useMediaCatalog.ts index a25a84a..998d2fe 100644 --- a/src/entities/media/model/useMediaCatalog.ts +++ b/src/entities/media/model/useMediaCatalog.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi'; import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest'; import { @@ -18,6 +18,7 @@ type MediaCatalogLoadResult = { let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST); let manifestRequest: Promise | null = null; +const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000; const readMediaManifest = async (signal?: AbortSignal): Promise => { if (!MEDIA_MANIFEST_URL) { @@ -69,17 +70,42 @@ export const useMediaCatalog = () => { const [error, setError] = useState(null); const [usedFallbackManifest, setUsedFallbackManifest] = useState(false); const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL); + const isMountedRef = useRef(false); + const lastRefreshAtRef = useRef(0); + + const applyLoadResult = useCallback((result: MediaCatalogLoadResult) => { + if (!isMountedRef.current) { + return; + } + + setManifest(result.manifest); + setError(result.error); + setUsedFallbackManifest(result.usedFallbackManifest); + setHasResolvedManifest(true); + }, []); + + const refreshManifest = useCallback(async () => { + try { + lastRefreshAtRef.current = Date.now(); + const result = await readMediaManifest(); + applyLoadResult(result); + return result; + } catch (err) { + if (err instanceof Error && err.name !== 'AbortError' && isMountedRef.current) { + console.error('Failed to refresh media manifest:', err); + } + return null; + } + }, [applyLoadResult]); useEffect(() => { + isMountedRef.current = true; const controller = new AbortController(); readMediaManifest(controller.signal) .then((result) => { if (!controller.signal.aborted) { - setManifest(result.manifest); - setError(result.error); - setUsedFallbackManifest(result.usedFallbackManifest); - setHasResolvedManifest(true); + applyLoadResult(result); } }) .catch((err) => { @@ -89,9 +115,69 @@ export const useMediaCatalog = () => { }); return () => { + isMountedRef.current = false; controller.abort(); }; - }, []); + }, [applyLoadResult]); + + useEffect(() => { + if (!MEDIA_MANIFEST_URL || (!usedFallbackManifest && !error)) { + return; + } + + const timeoutId = window.setTimeout(() => { + void refreshManifest(); + }, 4_000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [error, refreshManifest, usedFallbackManifest]); + + useEffect(() => { + if (!MEDIA_MANIFEST_URL) { + return; + } + + const maybeRefresh = () => { + if (document.visibilityState === 'hidden') { + return; + } + + if (Date.now() - lastRefreshAtRef.current < 15_000) { + return; + } + + void refreshManifest(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') { + return; + } + + maybeRefresh(); + }; + + const intervalId = window.setInterval(() => { + if (document.visibilityState !== 'visible') { + return; + } + + maybeRefresh(); + }, MANIFEST_REVALIDATE_INTERVAL_MS); + + window.addEventListener('focus', maybeRefresh); + window.addEventListener('online', maybeRefresh); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.clearInterval(intervalId); + window.removeEventListener('focus', maybeRefresh); + window.removeEventListener('online', maybeRefresh); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [refreshManifest]); const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]); const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]); @@ -103,5 +189,6 @@ export const useMediaCatalog = () => { error, usedFallbackManifest, hasResolvedManifest, + refreshManifest, }; }; diff --git a/src/features/sound-preset/model/useSoundPlayback.ts b/src/features/sound-preset/model/useSoundPlayback.ts index 9693da3..0663cf3 100644 --- a/src/features/sound-preset/model/useSoundPlayback.ts +++ b/src/features/sound-preset/model/useSoundPlayback.ts @@ -24,6 +24,18 @@ const resolvePlaybackErrorMessage = (error: unknown) => { return copy.soundPlayback.loadFailed; }; +const normalizeMediaUrl = (value: string | null | undefined) => { + if (!value || typeof window === 'undefined') { + return value ?? null; + } + + try { + return new URL(value, window.location.href).toString(); + } catch { + return value; + } +}; + export const useSoundPlayback = ({ selectedPresetId, soundAsset, @@ -33,6 +45,8 @@ export const useSoundPlayback = ({ }: UseSoundPlaybackOptions) => { const audioRef = useRef(null); const isPlaybackUnlockedRef = useRef(false); + const hasUserInteractedRef = useRef(false); + const recoveryAttemptedUrlRef = useRef(null); const requestedUrlRef = useRef(null); const [isReady, setReady] = useState(false); const [isPlaying, setPlaying] = useState(false); @@ -46,6 +60,10 @@ export const useSoundPlayback = ({ return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null; }, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]); + const normalizedActiveUrl = useMemo(() => { + return normalizeMediaUrl(activeUrl); + }, [activeUrl]); + useEffect(() => { if (typeof window === 'undefined') { return; @@ -55,6 +73,8 @@ export const useSoundPlayback = ({ audio.loop = true; audio.preload = 'auto'; isPlaybackUnlockedRef.current = false; + hasUserInteractedRef.current = false; + recoveryAttemptedUrlRef.current = null; const handleCanPlay = () => { setReady(true); @@ -121,8 +141,9 @@ export const useSoundPlayback = ({ selectedPresetId === 'silent' ? null : requestedUrl ?? activeUrl; + const normalizedNextUrl = normalizeMediaUrl(nextUrl); - if (!audio || !nextUrl) { + if (!audio || !nextUrl || !normalizedNextUrl) { isPlaybackUnlockedRef.current = true; return true; } @@ -135,8 +156,8 @@ export const useSoundPlayback = ({ const previousVolume = audio.volume; try { - if (audio.src !== nextUrl) { - audio.src = nextUrl; + if (audio.src !== normalizedNextUrl) { + audio.src = normalizedNextUrl; audio.load(); } @@ -160,6 +181,24 @@ export const useSoundPlayback = ({ } }, [activeUrl, selectedPresetId]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const markInteraction = () => { + hasUserInteractedRef.current = true; + }; + + window.addEventListener('pointerdown', markInteraction, { passive: true }); + window.addEventListener('keydown', markInteraction, { passive: true }); + + return () => { + window.removeEventListener('pointerdown', markInteraction); + window.removeEventListener('keydown', markInteraction); + }; + }, []); + useEffect(() => { const audio = audioRef.current; @@ -167,21 +206,71 @@ export const useSoundPlayback = ({ return; } - if (!activeUrl) { + if (!activeUrl || !normalizedActiveUrl) { audio.pause(); audio.removeAttribute('src'); requestedUrlRef.current = null; + recoveryAttemptedUrlRef.current = null; return; } - if (audio.src === activeUrl) { + if (audio.src === normalizedActiveUrl) { return; } - requestedUrlRef.current = activeUrl; - audio.src = activeUrl; + requestedUrlRef.current = normalizedActiveUrl; + audio.src = normalizedActiveUrl; audio.load(); - }, [activeUrl]); + }, [activeUrl, normalizedActiveUrl]); + + useEffect(() => { + if (!shouldPlay || !activeUrl || isPlaybackUnlockedRef.current) { + return; + } + + const attemptRecovery = () => { + if (recoveryAttemptedUrlRef.current === activeUrl) { + return; + } + + recoveryAttemptedUrlRef.current = activeUrl; + void unlockPlayback(activeUrl).then((didUnlock) => { + if (!didUnlock && recoveryAttemptedUrlRef.current === activeUrl) { + recoveryAttemptedUrlRef.current = null; + } + }); + }; + + attemptRecovery(); + + if (isPlaybackUnlockedRef.current) { + return; + } + + if (hasUserInteractedRef.current) { + return; + } + + let cancelled = false; + + const handleInteraction = () => { + if (cancelled) { + return; + } + + hasUserInteractedRef.current = true; + attemptRecovery(); + }; + + window.addEventListener('pointerdown', handleInteraction, { passive: true, once: true }); + window.addEventListener('keydown', handleInteraction, { passive: true, once: true }); + + return () => { + cancelled = true; + window.removeEventListener('pointerdown', handleInteraction); + window.removeEventListener('keydown', handleInteraction); + }; + }, [activeUrl, shouldPlay, unlockPlayback]); useEffect(() => { const audio = audioRef.current; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 52ee5d4..0fab0d2 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -1,9 +1,10 @@ "use client"; import { - getSceneStageBackgroundStyle, getSceneStagePhotoUrl, + loadAssetImage, preloadAssetImage, + resolveSceneStageLayerSources, useMediaCatalog, } from "@/entities/media"; import { getSceneById, SCENE_THEMES } from "@/entities/scene"; @@ -82,6 +83,9 @@ export const SpaceWorkspaceWidget = () => { useState("space-setup"); const [, setCurrentSessionThoughts] = useState([]); const [pendingCompletionResult, setPendingCompletionResult] = useState(null); + const [preferMobileStage, setPreferMobileStage] = useState(false); + const [preferHdStage, setPreferHdStage] = useState(false); + const [resolvedFinalStageUrl, setResolvedFinalStageUrl] = useState(null); const { selectedPresetId, @@ -183,6 +187,37 @@ export const SpaceWorkspaceWidget = () => { }); const didResolveEntryRouteRef = useRef(false); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mobileQuery = window.matchMedia("(max-width: 767px)"); + const wideQuery = window.matchMedia("(min-width: 1536px)"); + + const updateViewportPreference = () => { + const nextPreferMobile = mobileQuery.matches; + const nextPreferHd = + !nextPreferMobile && + wideQuery.matches && + window.devicePixelRatio >= 1.5; + + setPreferMobileStage(nextPreferMobile); + setPreferHdStage(nextPreferHd); + }; + + updateViewportPreference(); + mobileQuery.addEventListener("change", updateViewportPreference); + wideQuery.addEventListener("change", updateViewportPreference); + window.addEventListener("resize", updateViewportPreference); + + return () => { + mobileQuery.removeEventListener("change", updateViewportPreference); + wideQuery.removeEventListener("change", updateViewportPreference); + window.removeEventListener("resize", updateViewportPreference); + }; + }, []); + useEffect(() => { if (!isBootstrapping && !currentSession && !pendingCompletionResult) { router.replace("/app"); @@ -237,18 +272,125 @@ export const SpaceWorkspaceWidget = () => { }, [currentSessionId, pendingCompletionResult]); useEffect(() => { - const preferMobile = - typeof window !== "undefined" - ? window.matchMedia("(max-width: 767px)").matches - : false; preloadAssetImage( getSceneStagePhotoUrl( selection.selectedScene, selection.selectedSceneAsset, - { preferMobile }, + { preferMobile: preferMobileStage }, ), ); - }, [selection.selectedScene, selection.selectedSceneAsset]); + }, [preferMobileStage, selection.selectedScene, selection.selectedSceneAsset]); + + const stageLayerSources = useMemo(() => { + return resolveSceneStageLayerSources( + selection.selectedScene, + selection.selectedSceneAsset, + { + preferMobile: preferMobileStage, + preferHd: preferHdStage, + }, + ); + }, [ + preferHdStage, + preferMobileStage, + selection.selectedScene, + selection.selectedSceneAsset, + ]); + + useEffect(() => { + preloadAssetImage(stageLayerSources.baseImageUrl); + }, [stageLayerSources.baseImageUrl]); + + useEffect(() => { + const nextFinalUrl = stageLayerSources.finalImageUrl; + + if (!nextFinalUrl) { + if (!resolvedFinalStageUrl) { + return; + } + + const resetRafId = window.requestAnimationFrame(() => { + setResolvedFinalStageUrl(null); + }); + + return () => { + window.cancelAnimationFrame(resetRafId); + }; + } + + if (resolvedFinalStageUrl === nextFinalUrl) { + return; + } + + let active = true; + preloadAssetImage(nextFinalUrl); + + void loadAssetImage(nextFinalUrl).then((didLoad) => { + if (!active || !didLoad) { + return; + } + + setResolvedFinalStageUrl((currentUrl) => { + if (!active) { + return currentUrl; + } + + return currentUrl === nextFinalUrl ? currentUrl : nextFinalUrl; + }); + }); + + return () => { + active = false; + }; + }, [resolvedFinalStageUrl, selection.selectedScene.id, stageLayerSources.finalImageUrl]); + + const stageBackdropMotionClassName = + "absolute -inset-8 will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"; + const stageBackdropLayerClassName = + "absolute inset-0 bg-cover bg-center"; + + const underpaintStageStyle = useMemo(() => { + return { + backgroundImage: stageLayerSources.baseImageUrl + ? stageLayerSources.placeholderGradient + : stageLayerSources.underpaintUrl + ? `url('${stageLayerSources.underpaintUrl}'), ${stageLayerSources.placeholderGradient}` + : stageLayerSources.placeholderGradient, + backgroundSize: + !stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "cover, cover" : "cover", + backgroundPosition: + !stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "center, center" : "center", + backgroundRepeat: + !stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "no-repeat, no-repeat" : "no-repeat", + }; + }, [stageLayerSources.baseImageUrl, stageLayerSources.placeholderGradient, stageLayerSources.underpaintUrl]); + + const baseStageStyle = useMemo(() => { + return { + backgroundImage: stageLayerSources.baseImageUrl + ? `url('${stageLayerSources.baseImageUrl}'), ${stageLayerSources.placeholderGradient}` + : stageLayerSources.placeholderGradient, + backgroundSize: stageLayerSources.baseImageUrl ? "cover, cover" : "cover", + backgroundPosition: stageLayerSources.baseImageUrl ? "center, center" : "center", + backgroundRepeat: stageLayerSources.baseImageUrl ? "no-repeat, no-repeat" : "no-repeat", + }; + }, [stageLayerSources.baseImageUrl, stageLayerSources.placeholderGradient]); + + const finalStageStyle = useMemo(() => { + if (!resolvedFinalStageUrl) { + return null; + } + + return { + backgroundImage: `url('${resolvedFinalStageUrl}'), ${stageLayerSources.placeholderGradient}`, + backgroundSize: "cover, cover", + backgroundPosition: "center, center", + backgroundRepeat: "no-repeat, no-repeat", + }; + }, [resolvedFinalStageUrl, stageLayerSources.placeholderGradient]); + const isFinalStageVisible = + Boolean(resolvedFinalStageUrl) && + resolvedFinalStageUrl === stageLayerSources.finalImageUrl; const resolvedTimeDisplay = timeDisplay ?? @@ -256,14 +398,20 @@ export const SpaceWorkspaceWidget = () => { return (
-
+
+
+
+
+