fix(space): 미디어 재진입 복원 안정화
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<boolean>((resolve) => {
|
||||
const image = new window.Image();
|
||||
image.decoding = 'async';
|
||||
|
||||
image.onload = () => {
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<MediaCatalogLoadResult> | null = null;
|
||||
const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000;
|
||||
|
||||
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
@@ -69,17 +70,42 @@ export const useMediaCatalog = () => {
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<HTMLAudioElement | null>(null);
|
||||
const isPlaybackUnlockedRef = useRef(false);
|
||||
const hasUserInteractedRef = useRef(false);
|
||||
const recoveryAttemptedUrlRef = useRef<string | null>(null);
|
||||
const requestedUrlRef = useRef<string | null>(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;
|
||||
|
||||
@@ -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<SessionEntryPoint>("space-setup");
|
||||
const [, setCurrentSessionThoughts] = useState<CurrentSessionThought[]>([]);
|
||||
const [pendingCompletionResult, setPendingCompletionResult] = useState<CompletionResult | null>(null);
|
||||
const [preferMobileStage, setPreferMobileStage] = useState(false);
|
||||
const [preferHdStage, setPreferHdStage] = useState(false);
|
||||
const [resolvedFinalStageUrl, setResolvedFinalStageUrl] = useState<string | null>(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 (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
<div aria-hidden className={stageBackdropMotionClassName}>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
|
||||
style={getSceneStageBackgroundStyle(
|
||||
selection.selectedScene,
|
||||
selection.selectedSceneAsset,
|
||||
)}
|
||||
className={stageBackdropLayerClassName}
|
||||
style={underpaintStageStyle}
|
||||
/>
|
||||
<div
|
||||
className={stageBackdropLayerClassName}
|
||||
style={baseStageStyle}
|
||||
/>
|
||||
<div
|
||||
className={`${stageBackdropLayerClassName} transition-opacity duration-[320ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${isFinalStageVisible ? "opacity-100" : "opacity-0"}`}
|
||||
style={finalStageStyle ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<main className="relative flex-1" />
|
||||
|
||||
Reference in New Issue
Block a user