fix(space): 미디어 재진입 복원 안정화
This commit is contained in:
@@ -20,11 +20,15 @@ const resolveMediaManifestUrl = () => {
|
|||||||
const resolveManifestFetchCache = (): RequestCache => {
|
const resolveManifestFetchCache = (): RequestCache => {
|
||||||
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
|
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
|
||||||
|
|
||||||
if (configuredCacheMode === 'no-store' || configuredCacheMode === 'force-cache') {
|
if (configuredCacheMode === 'no-store') {
|
||||||
return configuredCacheMode;
|
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();
|
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 isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
|
||||||
|
|
||||||
const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => {
|
const appendCacheBustParam = (value: string, cacheBustKey?: string | null) => {
|
||||||
if (!value) {
|
if (!cacheBustKey) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAbsoluteUrl(value) || value.startsWith('/')) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!baseUrl) {
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return value;
|
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 mergeSceneAssets = (manifest: MediaManifest) => {
|
||||||
|
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||||
const bySceneId = new Map(
|
const bySceneId = new Map(
|
||||||
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
|
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
|
||||||
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
||||||
@@ -64,14 +96,15 @@ const mergeSceneAssets = (manifest: MediaManifest) => {
|
|||||||
|
|
||||||
return Array.from(bySceneId.values()).map((asset) => ({
|
return Array.from(bySceneId.values()).map((asset) => ({
|
||||||
...asset,
|
...asset,
|
||||||
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl) ?? asset.cardUrl,
|
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl, cacheBustKey) ?? asset.cardUrl,
|
||||||
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl),
|
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl),
|
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl),
|
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergeSoundAssets = (manifest: MediaManifest) => {
|
const mergeSoundAssets = (manifest: MediaManifest) => {
|
||||||
|
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||||
const byPresetId = new Map(
|
const byPresetId = new Map(
|
||||||
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
|
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
|
||||||
asset.presetId,
|
asset.presetId,
|
||||||
@@ -92,9 +125,9 @@ const mergeSoundAssets = (manifest: MediaManifest) => {
|
|||||||
|
|
||||||
return Array.from(byPresetId.values()).map((asset) => ({
|
return Array.from(byPresetId.values()).map((asset) => ({
|
||||||
...asset,
|
...asset,
|
||||||
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl),
|
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl),
|
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl),
|
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,6 +183,44 @@ export const getSceneStagePhotoUrl = (
|
|||||||
return getSceneCardPhotoUrl(scene, asset);
|
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 = (
|
export const getSceneCardBackgroundStyle = (
|
||||||
scene: SceneTheme,
|
scene: SceneTheme,
|
||||||
asset?: SceneAssetManifestItem | null,
|
asset?: SceneAssetManifestItem | null,
|
||||||
@@ -186,3 +257,24 @@ export const preloadAssetImage = (url: string | null | undefined) => {
|
|||||||
image.decoding = 'async';
|
image.decoding = 'async';
|
||||||
image.src = url;
|
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';
|
'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 { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi';
|
||||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,7 @@ type MediaCatalogLoadResult = {
|
|||||||
|
|
||||||
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
||||||
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
|
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
|
||||||
|
const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
|
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
|
||||||
if (!MEDIA_MANIFEST_URL) {
|
if (!MEDIA_MANIFEST_URL) {
|
||||||
@@ -69,17 +70,42 @@ export const useMediaCatalog = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
|
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
|
||||||
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
|
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(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
readMediaManifest(controller.signal)
|
readMediaManifest(controller.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setManifest(result.manifest);
|
applyLoadResult(result);
|
||||||
setError(result.error);
|
|
||||||
setUsedFallbackManifest(result.usedFallbackManifest);
|
|
||||||
setHasResolvedManifest(true);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -89,9 +115,69 @@ export const useMediaCatalog = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
controller.abort();
|
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 sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]);
|
||||||
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
|
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
|
||||||
@@ -103,5 +189,6 @@ export const useMediaCatalog = () => {
|
|||||||
error,
|
error,
|
||||||
usedFallbackManifest,
|
usedFallbackManifest,
|
||||||
hasResolvedManifest,
|
hasResolvedManifest,
|
||||||
|
refreshManifest,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ const resolvePlaybackErrorMessage = (error: unknown) => {
|
|||||||
return copy.soundPlayback.loadFailed;
|
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 = ({
|
export const useSoundPlayback = ({
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
soundAsset,
|
soundAsset,
|
||||||
@@ -33,6 +45,8 @@ export const useSoundPlayback = ({
|
|||||||
}: UseSoundPlaybackOptions) => {
|
}: UseSoundPlaybackOptions) => {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const isPlaybackUnlockedRef = useRef(false);
|
const isPlaybackUnlockedRef = useRef(false);
|
||||||
|
const hasUserInteractedRef = useRef(false);
|
||||||
|
const recoveryAttemptedUrlRef = useRef<string | null>(null);
|
||||||
const requestedUrlRef = useRef<string | null>(null);
|
const requestedUrlRef = useRef<string | null>(null);
|
||||||
const [isReady, setReady] = useState(false);
|
const [isReady, setReady] = useState(false);
|
||||||
const [isPlaying, setPlaying] = useState(false);
|
const [isPlaying, setPlaying] = useState(false);
|
||||||
@@ -46,6 +60,10 @@ export const useSoundPlayback = ({
|
|||||||
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
|
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
|
||||||
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
|
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
|
||||||
|
|
||||||
|
const normalizedActiveUrl = useMemo(() => {
|
||||||
|
return normalizeMediaUrl(activeUrl);
|
||||||
|
}, [activeUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -55,6 +73,8 @@ export const useSoundPlayback = ({
|
|||||||
audio.loop = true;
|
audio.loop = true;
|
||||||
audio.preload = 'auto';
|
audio.preload = 'auto';
|
||||||
isPlaybackUnlockedRef.current = false;
|
isPlaybackUnlockedRef.current = false;
|
||||||
|
hasUserInteractedRef.current = false;
|
||||||
|
recoveryAttemptedUrlRef.current = null;
|
||||||
|
|
||||||
const handleCanPlay = () => {
|
const handleCanPlay = () => {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@@ -121,8 +141,9 @@ export const useSoundPlayback = ({
|
|||||||
selectedPresetId === 'silent'
|
selectedPresetId === 'silent'
|
||||||
? null
|
? null
|
||||||
: requestedUrl ?? activeUrl;
|
: requestedUrl ?? activeUrl;
|
||||||
|
const normalizedNextUrl = normalizeMediaUrl(nextUrl);
|
||||||
|
|
||||||
if (!audio || !nextUrl) {
|
if (!audio || !nextUrl || !normalizedNextUrl) {
|
||||||
isPlaybackUnlockedRef.current = true;
|
isPlaybackUnlockedRef.current = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -135,8 +156,8 @@ export const useSoundPlayback = ({
|
|||||||
const previousVolume = audio.volume;
|
const previousVolume = audio.volume;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (audio.src !== nextUrl) {
|
if (audio.src !== normalizedNextUrl) {
|
||||||
audio.src = nextUrl;
|
audio.src = normalizedNextUrl;
|
||||||
audio.load();
|
audio.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +181,24 @@ export const useSoundPlayback = ({
|
|||||||
}
|
}
|
||||||
}, [activeUrl, selectedPresetId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
|
|
||||||
@@ -167,21 +206,71 @@ export const useSoundPlayback = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeUrl) {
|
if (!activeUrl || !normalizedActiveUrl) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute('src');
|
audio.removeAttribute('src');
|
||||||
requestedUrlRef.current = null;
|
requestedUrlRef.current = null;
|
||||||
|
recoveryAttemptedUrlRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audio.src === activeUrl) {
|
if (audio.src === normalizedActiveUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedUrlRef.current = activeUrl;
|
requestedUrlRef.current = normalizedActiveUrl;
|
||||||
audio.src = activeUrl;
|
audio.src = normalizedActiveUrl;
|
||||||
audio.load();
|
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(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSceneStageBackgroundStyle,
|
|
||||||
getSceneStagePhotoUrl,
|
getSceneStagePhotoUrl,
|
||||||
|
loadAssetImage,
|
||||||
preloadAssetImage,
|
preloadAssetImage,
|
||||||
|
resolveSceneStageLayerSources,
|
||||||
useMediaCatalog,
|
useMediaCatalog,
|
||||||
} from "@/entities/media";
|
} from "@/entities/media";
|
||||||
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
|
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
|
||||||
@@ -82,6 +83,9 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
useState<SessionEntryPoint>("space-setup");
|
useState<SessionEntryPoint>("space-setup");
|
||||||
const [, setCurrentSessionThoughts] = useState<CurrentSessionThought[]>([]);
|
const [, setCurrentSessionThoughts] = useState<CurrentSessionThought[]>([]);
|
||||||
const [pendingCompletionResult, setPendingCompletionResult] = useState<CompletionResult | null>(null);
|
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 {
|
const {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
@@ -183,6 +187,37 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
});
|
});
|
||||||
const didResolveEntryRouteRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isBootstrapping && !currentSession && !pendingCompletionResult) {
|
if (!isBootstrapping && !currentSession && !pendingCompletionResult) {
|
||||||
router.replace("/app");
|
router.replace("/app");
|
||||||
@@ -237,18 +272,125 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}, [currentSessionId, pendingCompletionResult]);
|
}, [currentSessionId, pendingCompletionResult]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferMobile =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.matchMedia("(max-width: 767px)").matches
|
|
||||||
: false;
|
|
||||||
preloadAssetImage(
|
preloadAssetImage(
|
||||||
getSceneStagePhotoUrl(
|
getSceneStagePhotoUrl(
|
||||||
selection.selectedScene,
|
selection.selectedScene,
|
||||||
selection.selectedSceneAsset,
|
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 =
|
const resolvedTimeDisplay =
|
||||||
timeDisplay ??
|
timeDisplay ??
|
||||||
@@ -256,14 +398,20 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh overflow-hidden text-white">
|
<div className="relative h-dvh overflow-hidden text-white">
|
||||||
|
<div aria-hidden className={stageBackdropMotionClassName}>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
className={stageBackdropLayerClassName}
|
||||||
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={underpaintStageStyle}
|
||||||
style={getSceneStageBackgroundStyle(
|
|
||||||
selection.selectedScene,
|
|
||||||
selection.selectedSceneAsset,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<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">
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
<main className="relative flex-1" />
|
<main className="relative flex-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user