feat(space): persist media selection and stabilize sound playback
This commit is contained in:
@@ -3,7 +3,32 @@ import { normalizeMediaManifest } from '../model/resolveMediaAsset';
|
||||
import type { MediaManifest } from '../model/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const MEDIA_MANIFEST_URL = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||
const resolveMediaManifestUrl = () => {
|
||||
const explicitManifestUrl = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||
if (explicitManifestUrl) {
|
||||
return explicitManifestUrl;
|
||||
}
|
||||
|
||||
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
if (!apiBaseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${apiBaseUrl.replace(/\/$/, '')}/api/v1/media/manifest`;
|
||||
};
|
||||
|
||||
const resolveManifestFetchCache = (): RequestCache => {
|
||||
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
|
||||
|
||||
if (configuredCacheMode === 'no-store' || configuredCacheMode === 'force-cache') {
|
||||
return configuredCacheMode;
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === 'development' ? 'no-store' : 'force-cache';
|
||||
};
|
||||
|
||||
export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl();
|
||||
export const MEDIA_MANIFEST_FETCH_CACHE = resolveManifestFetchCache();
|
||||
|
||||
export const mediaManifestApi = {
|
||||
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
||||
@@ -13,7 +38,7 @@ export const mediaManifestApi = {
|
||||
|
||||
const response = await fetch(MEDIA_MANIFEST_URL, {
|
||||
method: 'GET',
|
||||
cache: 'force-cache',
|
||||
cache: MEDIA_MANIFEST_FETCH_CACHE,
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { mediaManifestApi } from '../api/mediaManifestApi';
|
||||
import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi';
|
||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||
import {
|
||||
buildSceneAssetMap,
|
||||
@@ -14,7 +14,7 @@ let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
||||
let manifestRequest: Promise<MediaManifest> | null = null;
|
||||
|
||||
const readMediaManifest = async (signal?: AbortSignal) => {
|
||||
if (!process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL) {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
return manifestCache;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ const HUB_CURATION_ORDER = [
|
||||
'quiet-library',
|
||||
'rain-window',
|
||||
'dawn-cafe',
|
||||
'green-forest',
|
||||
'forest',
|
||||
'fireplace',
|
||||
] as const;
|
||||
|
||||
const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
||||
const SCENE_ID_ALIASES: Record<string, string> = {
|
||||
'green-forest': 'forest',
|
||||
};
|
||||
|
||||
export const SCENE_THEMES: SceneTheme[] = [
|
||||
{
|
||||
@@ -102,12 +105,12 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
'radial-gradient(125% 125% at 75% 8%, rgba(56,189,248,0.4) 0%, rgba(15,23,42,0) 50%), linear-gradient(145deg, rgba(8,47,73,0.93) 0%, rgba(15,23,42,0.9) 55%, rgba(3,7,18,0.96) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'green-forest',
|
||||
id: 'forest',
|
||||
name: copy.scenes[4].name,
|
||||
description: copy.scenes[4].description,
|
||||
tags: [...copy.scenes[4].tags],
|
||||
recommendedSound: copy.scenes[4].recommendedSound,
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedSoundPresetId: 'forest-birds',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: copy.scenes[4].recommendedTime,
|
||||
vibeLabel: copy.scenes[4].vibeLabel,
|
||||
@@ -236,7 +239,16 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
];
|
||||
|
||||
export const getSceneById = (id: string) => {
|
||||
return SCENE_THEMES.find((scene) => scene.id === id);
|
||||
const normalizedSceneId = normalizeSceneId(id);
|
||||
return SCENE_THEMES.find((scene) => scene.id === normalizedSceneId);
|
||||
};
|
||||
|
||||
export const normalizeSceneId = (id: string | null | undefined) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SCENE_ID_ALIASES[id] ?? id;
|
||||
};
|
||||
|
||||
export const getSceneCardPhotoUrl = (scene: SceneTheme) => {
|
||||
@@ -280,7 +292,8 @@ export const getHubSceneSections = (
|
||||
recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
|
||||
) => {
|
||||
const sceneById = new Map(scenes.map((scene) => [scene.id, scene] as const));
|
||||
const selectedScene = sceneById.get(selectedSceneId);
|
||||
const normalizedSelectedSceneId = normalizeSceneId(selectedSceneId) ?? selectedSceneId;
|
||||
const selectedScene = sceneById.get(normalizedSelectedSceneId);
|
||||
const curatedScenes = HUB_CURATION_ORDER.map((id) => sceneById.get(id));
|
||||
|
||||
const recommendedScenes = uniqueBySceneId([
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface CompleteFocusSessionRequest {
|
||||
completedGoal?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
}
|
||||
|
||||
export const focusSessionApi = {
|
||||
/**
|
||||
* Backend Codex:
|
||||
@@ -102,6 +107,21 @@ export const focusSessionApi = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - Space 우측 패널에서 scene/sound를 바꿨을 때 현재 활성 세션의 선택값을 patch 방식으로 갱신한다.
|
||||
* - sceneId, soundPresetId 중 일부만 보내도 된다.
|
||||
* - 응답은 갱신 후 최신 current session 스냅샷을 반환한다.
|
||||
*/
|
||||
updateCurrentSelection: async (
|
||||
payload: UpdateCurrentFocusSessionSelectionRequest,
|
||||
): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/selection', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FocusSessionPhase,
|
||||
type FocusSessionState,
|
||||
type StartFocusSessionRequest,
|
||||
type UpdateCurrentFocusSessionSelectionRequest,
|
||||
} from '../api/focusSessionApi';
|
||||
|
||||
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
||||
@@ -69,6 +70,7 @@ interface UseFocusSessionEngineResult {
|
||||
pauseSession: () => Promise<FocusSession | null>;
|
||||
resumeSession: () => Promise<FocusSession | null>;
|
||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||
abandonSession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
@@ -230,6 +232,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
updateCurrentSelection: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.updateCurrentSelection(payload),
|
||||
copy.focusSession.syncFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
completeSession: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface UserFocusPreferences {
|
||||
reduceMotion: boolean;
|
||||
notificationIntensity: NotificationIntensity;
|
||||
defaultPresetId: DefaultPresetId;
|
||||
defaultSceneId: string | null;
|
||||
defaultSoundPresetId: string | null;
|
||||
}
|
||||
|
||||
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||
@@ -20,6 +22,8 @@ export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||
reduceMotion: false,
|
||||
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
||||
defaultSceneId: null,
|
||||
defaultSoundPresetId: null,
|
||||
};
|
||||
|
||||
export const preferencesApi = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SoundAssetManifestItem } from '@/entities/media';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
@@ -16,6 +16,14 @@ const clampVolume = (value: number) => {
|
||||
return Math.min(1, Math.max(0, value / 100));
|
||||
};
|
||||
|
||||
const resolvePlaybackErrorMessage = (error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
return copy.soundPlayback.browserDeferred;
|
||||
}
|
||||
|
||||
return copy.soundPlayback.loadFailed;
|
||||
};
|
||||
|
||||
export const useSoundPlayback = ({
|
||||
selectedPresetId,
|
||||
soundAsset,
|
||||
@@ -24,6 +32,8 @@ export const useSoundPlayback = ({
|
||||
shouldPlay,
|
||||
}: UseSoundPlaybackOptions) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isPlaybackUnlockedRef = useRef(false);
|
||||
const requestedUrlRef = useRef<string | null>(null);
|
||||
const [isReady, setReady] = useState(false);
|
||||
const [isPlaying, setPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -44,7 +54,7 @@ export const useSoundPlayback = ({
|
||||
const audio = new window.Audio();
|
||||
audio.loop = true;
|
||||
audio.preload = 'auto';
|
||||
audio.crossOrigin = 'anonymous';
|
||||
isPlaybackUnlockedRef.current = false;
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setReady(true);
|
||||
@@ -65,6 +75,10 @@ export const useSoundPlayback = ({
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (!requestedUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReady(false);
|
||||
setPlaying(false);
|
||||
setError(copy.soundPlayback.loadFailed);
|
||||
@@ -101,6 +115,51 @@ export const useSoundPlayback = ({
|
||||
audio.muted = isMuted;
|
||||
}, [isMuted, masterVolume]);
|
||||
|
||||
const unlockPlayback = useCallback(async (requestedUrl?: string | null) => {
|
||||
const audio = audioRef.current;
|
||||
const nextUrl =
|
||||
selectedPresetId === 'silent'
|
||||
? null
|
||||
: requestedUrl ?? activeUrl;
|
||||
|
||||
if (!audio || !nextUrl) {
|
||||
isPlaybackUnlockedRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlaybackUnlockedRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousMuted = audio.muted;
|
||||
const previousVolume = audio.volume;
|
||||
|
||||
try {
|
||||
if (audio.src !== nextUrl) {
|
||||
audio.src = nextUrl;
|
||||
audio.load();
|
||||
}
|
||||
|
||||
audio.muted = true;
|
||||
audio.volume = 0;
|
||||
await audio.play();
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.muted = previousMuted;
|
||||
audio.volume = previousVolume;
|
||||
isPlaybackUnlockedRef.current = true;
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (playbackError) {
|
||||
audio.pause();
|
||||
audio.muted = previousMuted;
|
||||
audio.volume = previousVolume;
|
||||
setPlaying(false);
|
||||
setError(resolvePlaybackErrorMessage(playbackError));
|
||||
return false;
|
||||
}
|
||||
}, [activeUrl, selectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
@@ -111,7 +170,7 @@ export const useSoundPlayback = ({
|
||||
if (!activeUrl) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
requestedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,6 +178,7 @@ export const useSoundPlayback = ({
|
||||
return;
|
||||
}
|
||||
|
||||
requestedUrlRef.current = activeUrl;
|
||||
audio.src = activeUrl;
|
||||
audio.load();
|
||||
}, [activeUrl]);
|
||||
@@ -135,6 +195,11 @@ export const useSoundPlayback = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaybackUnlockedRef.current) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const playAudio = async () => {
|
||||
@@ -144,10 +209,10 @@ export const useSoundPlayback = ({
|
||||
if (!cancelled) {
|
||||
setError(null);
|
||||
}
|
||||
} catch {
|
||||
} catch (playbackError) {
|
||||
if (!cancelled) {
|
||||
setPlaying(false);
|
||||
setError(copy.soundPlayback.browserDeferred);
|
||||
setError(resolvePlaybackErrorMessage(playbackError));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -164,5 +229,6 @@ export const useSoundPlayback = ({
|
||||
isReady,
|
||||
isPlaying,
|
||||
error,
|
||||
unlockPlayback,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -363,6 +363,7 @@ export const ko = {
|
||||
soundPresets: [
|
||||
{ id: 'deep-white', label: 'Deep White' },
|
||||
{ id: 'rain-focus', label: 'Rain Focus' },
|
||||
{ id: 'forest-birds', label: 'Forest Birds' },
|
||||
{ id: 'cafe-work', label: 'Cafe Work' },
|
||||
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
||||
{ id: 'fireplace', label: 'Fireplace' },
|
||||
@@ -451,11 +452,11 @@ export const ko = {
|
||||
vibeLabel: '차분함',
|
||||
},
|
||||
{
|
||||
id: 'green-forest',
|
||||
id: 'forest',
|
||||
name: '숲',
|
||||
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
||||
tags: ['저자극', '움직임 적음'],
|
||||
recommendedSound: 'Forest Hush',
|
||||
recommendedSound: 'Forest Birds',
|
||||
recommendedTime: '오전',
|
||||
vibeLabel: '맑음',
|
||||
},
|
||||
@@ -704,6 +705,8 @@ export const ko = {
|
||||
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||
nextGoalReady: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
|
||||
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
||||
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
|
||||
},
|
||||
exitHold: {
|
||||
holdToExitAriaLabel: '길게 눌러 나가기',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
getSceneById,
|
||||
normalizeSceneId,
|
||||
SCENE_THEMES,
|
||||
} from '@/entities/scene';
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
|
||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
@@ -70,12 +72,14 @@ const readStoredWorkspaceSelection = (): StoredWorkspaceSelection => {
|
||||
};
|
||||
|
||||
const resolveInitialSceneId = (sceneIdFromQuery: string | null, storedSceneId?: string) => {
|
||||
if (sceneIdFromQuery && getSceneById(sceneIdFromQuery)) {
|
||||
return sceneIdFromQuery;
|
||||
const normalizedQuerySceneId = normalizeSceneId(sceneIdFromQuery);
|
||||
if (normalizedQuerySceneId && getSceneById(normalizedQuerySceneId)) {
|
||||
return normalizedQuerySceneId;
|
||||
}
|
||||
|
||||
if (storedSceneId && getSceneById(storedSceneId)) {
|
||||
return storedSceneId;
|
||||
const normalizedStoredSceneId = normalizeSceneId(storedSceneId);
|
||||
if (normalizedStoredSceneId && getSceneById(normalizedStoredSceneId)) {
|
||||
return normalizedStoredSceneId;
|
||||
}
|
||||
|
||||
return SCENE_THEMES[0].id;
|
||||
@@ -204,6 +208,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
||||
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
||||
const didHydrateServerPreferencesRef = useRef(false);
|
||||
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
||||
|
||||
const {
|
||||
@@ -224,6 +229,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentSelection,
|
||||
completeSession,
|
||||
abandonSession,
|
||||
} = useFocusSessionEngine();
|
||||
@@ -279,6 +285,52 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}
|
||||
}, [selectionOverride, setSelectedPresetId]);
|
||||
|
||||
const persistSpaceSelection = useCallback((selection: {
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
}) => {
|
||||
const preferencePayload: {
|
||||
defaultSceneId?: string | null;
|
||||
defaultSoundPresetId?: string | null;
|
||||
} = {};
|
||||
const currentSessionPayload: {
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
} = {};
|
||||
|
||||
if (selection.sceneId !== undefined) {
|
||||
preferencePayload.defaultSceneId = selection.sceneId;
|
||||
currentSessionPayload.sceneId = selection.sceneId;
|
||||
}
|
||||
|
||||
if (selection.soundPresetId !== undefined) {
|
||||
preferencePayload.defaultSoundPresetId = selection.soundPresetId;
|
||||
currentSessionPayload.soundPresetId = selection.soundPresetId;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const [preferencesResult, sessionResult] = await Promise.allSettled([
|
||||
preferencesApi.updateFocusPreferences(preferencePayload),
|
||||
currentSession ? updateCurrentSelection(currentSessionPayload) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (preferencesResult.status === 'rejected') {
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.selectionPreferenceSaveFailed,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
currentSession &&
|
||||
(sessionResult.status === 'rejected' || sessionResult.value === null)
|
||||
) {
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.selectionSessionSyncFailed,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [currentSession, pushStatusLine, updateCurrentSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedSelection = readStoredWorkspaceSelection();
|
||||
const restoredSelectionOverride: SelectionOverride = {
|
||||
@@ -287,7 +339,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
};
|
||||
const restoredSceneId =
|
||||
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
|
||||
? storedSelection.sceneId
|
||||
? normalizeSceneId(storedSelection.sceneId)
|
||||
: null;
|
||||
const restoredTimerLabel = !timerQuery
|
||||
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
|
||||
@@ -325,6 +377,50 @@ export const SpaceWorkspaceWidget = () => {
|
||||
};
|
||||
}, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
didHydrateServerPreferencesRef.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
void preferencesApi
|
||||
.getFocusPreferences()
|
||||
.then((preferences) => {
|
||||
if (cancelled || currentSession || hasQueryOverrides) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPreferredSceneId = normalizeSceneId(preferences.defaultSceneId);
|
||||
const nextSceneId =
|
||||
normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId)
|
||||
? normalizedPreferredSceneId
|
||||
: null;
|
||||
const nextSoundPresetId =
|
||||
preferences.defaultSoundPresetId &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === preferences.defaultSoundPresetId)
|
||||
? preferences.defaultSoundPresetId
|
||||
: null;
|
||||
|
||||
if (nextSceneId) {
|
||||
setSelectedSceneId(nextSceneId);
|
||||
}
|
||||
|
||||
if (nextSoundPresetId) {
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setSelectionOverride((current) => ({ ...current, sound: true }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Focus preference load failure should not block entering the space.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSession, hasHydratedSelection, hasQueryOverrides, setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession) {
|
||||
return;
|
||||
@@ -338,7 +434,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
? currentSession.soundPresetId
|
||||
: selectedPresetId;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
setSelectedSceneId(currentSession.sceneId);
|
||||
setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId);
|
||||
setSelectedTimerLabel(nextTimerLabel);
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setGoalInput(currentSession.goal);
|
||||
@@ -360,7 +456,16 @@ export const SpaceWorkspaceWidget = () => {
|
||||
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
|
||||
}, [selectedScene, selectedSceneAsset]);
|
||||
|
||||
const { error: soundPlaybackError } = useSoundPlayback({
|
||||
const resolveSoundPlaybackUrl = useCallback((presetId: string) => {
|
||||
if (presetId === 'silent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = soundAssetMap[presetId];
|
||||
return asset?.loopUrl ?? asset?.fallbackLoopUrl ?? null;
|
||||
}, [soundAssetMap]);
|
||||
|
||||
const { error: soundPlaybackError, unlockPlayback } = useSoundPlayback({
|
||||
selectedPresetId,
|
||||
soundAsset: soundAssetMap[selectedPresetId],
|
||||
masterVolume,
|
||||
@@ -369,8 +474,27 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
|
||||
const handleSelectScene = (sceneId: string) => {
|
||||
setSelectedSceneId(sceneId);
|
||||
applyRecommendedSelections(sceneId);
|
||||
void (async () => {
|
||||
const normalizedSceneId = normalizeSceneId(sceneId) ?? sceneId;
|
||||
const recommendedScene = getSceneById(normalizedSceneId);
|
||||
const nextSoundPresetId =
|
||||
recommendedScene &&
|
||||
!selectionOverride.sound &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === recommendedScene.recommendedSoundPresetId)
|
||||
? recommendedScene.recommendedSoundPresetId
|
||||
: selectedPresetId;
|
||||
|
||||
if (shouldPlaySound) {
|
||||
await unlockPlayback(resolveSoundPlaybackUrl(nextSoundPresetId));
|
||||
}
|
||||
|
||||
setSelectedSceneId(normalizedSceneId);
|
||||
applyRecommendedSelections(normalizedSceneId);
|
||||
persistSpaceSelection({
|
||||
sceneId: normalizedSceneId,
|
||||
soundPresetId: nextSoundPresetId,
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSelectTimer = (timerLabel: string, markOverride = false) => {
|
||||
@@ -390,19 +514,29 @@ export const SpaceWorkspaceWidget = () => {
|
||||
};
|
||||
|
||||
const handleSelectSound = (presetId: string, markOverride = false) => {
|
||||
setSelectedPresetId(presetId);
|
||||
|
||||
if (!markOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionOverride((current) => {
|
||||
if (current.sound) {
|
||||
return current;
|
||||
void (async () => {
|
||||
if (shouldPlaySound) {
|
||||
await unlockPlayback(resolveSoundPlaybackUrl(presetId));
|
||||
}
|
||||
|
||||
return { ...current, sound: true };
|
||||
});
|
||||
setSelectedPresetId(presetId);
|
||||
|
||||
if (!markOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionOverride((current) => {
|
||||
if (current.sound) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, sound: true };
|
||||
});
|
||||
|
||||
persistSpaceSelection({
|
||||
soundPresetId: presetId,
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
const handleGoalChipSelect = (chip: GoalChip) => {
|
||||
@@ -449,7 +583,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}
|
||||
|
||||
const startedSession = await startSession({
|
||||
sceneId: selectedSceneId,
|
||||
sceneId: selectedScene.id,
|
||||
goal: trimmedGoal,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
@@ -480,6 +614,8 @@ export const SpaceWorkspaceWidget = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
|
||||
|
||||
if (!currentSession) {
|
||||
await startFocusFlow();
|
||||
return;
|
||||
@@ -604,15 +740,15 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
sceneId: selectedSceneId,
|
||||
JSON.stringify({
|
||||
sceneId: selectedScene.id,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
goal: normalizedGoal,
|
||||
override: selectionOverride,
|
||||
}),
|
||||
);
|
||||
}, [goalInput, hasHydratedSelection, resumeGoal, selectedSceneId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
|
||||
}, [goalInput, hasHydratedSelection, resumeGoal, selectedScene.id, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocusMode || !queuedFocusStatusMessageRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user