From 9811134d8aa5d2b3124949d24f2d62d6e3bfd9b7 Mon Sep 17 00:00:00 2001 From: corpi Date: Tue, 10 Mar 2026 17:36:10 +0900 Subject: [PATCH] feat(space): persist media selection and stabilize sound playback --- src/entities/media/api/mediaManifestApi.ts | 29 ++- src/entities/media/model/useMediaCatalog.ts | 4 +- src/entities/scene/model/scenes.ts | 23 ++- .../focus-session/api/focusSessionApi.ts | 20 ++ .../model/useFocusSessionEngine.ts | 14 ++ .../preferences/api/preferencesApi.ts | 4 + .../sound-preset/model/useSoundPlayback.ts | 76 +++++++- src/shared/i18n/ko.ts | 7 +- .../ui/SpaceWorkspaceWidget.tsx | 184 +++++++++++++++--- 9 files changed, 321 insertions(+), 40 deletions(-) diff --git a/src/entities/media/api/mediaManifestApi.ts b/src/entities/media/api/mediaManifestApi.ts index 235f6cf..9460f23 100644 --- a/src/entities/media/api/mediaManifestApi.ts +++ b/src/entities/media/api/mediaManifestApi.ts @@ -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 => { @@ -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, }); diff --git a/src/entities/media/model/useMediaCatalog.ts b/src/entities/media/model/useMediaCatalog.ts index 8189c36..3180a28 100644 --- a/src/entities/media/model/useMediaCatalog.ts +++ b/src/entities/media/model/useMediaCatalog.ts @@ -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 | null = null; const readMediaManifest = async (signal?: AbortSignal) => { - if (!process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL) { + if (!MEDIA_MANIFEST_URL) { return manifestCache; } diff --git a/src/entities/scene/model/scenes.ts b/src/entities/scene/model/scenes.ts index 11401cc..bef5b97 100644 --- a/src/entities/scene/model/scenes.ts +++ b/src/entities/scene/model/scenes.ts @@ -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 = { + '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([ diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 288728b..e5bb0a4 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -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 => { + return apiClient('api/v1/focus-sessions/current/selection', { + method: 'PATCH', + body: JSON.stringify(payload), + }); + }, + /** * Backend Codex: * - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다. diff --git a/src/features/focus-session/model/useFocusSessionEngine.ts b/src/features/focus-session/model/useFocusSessionEngine.ts index 317c432..43b1eaf 100644 --- a/src/features/focus-session/model/useFocusSessionEngine.ts +++ b/src/features/focus-session/model/useFocusSessionEngine.ts @@ -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; resumeSession: () => Promise; restartCurrentPhase: () => Promise; + updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise; completeSession: (payload: CompleteFocusSessionRequest) => Promise; abandonSession: () => Promise; 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; diff --git a/src/features/preferences/api/preferencesApi.ts b/src/features/preferences/api/preferencesApi.ts index 0c0bd19..43052ac 100644 --- a/src/features/preferences/api/preferencesApi.ts +++ b/src/features/preferences/api/preferencesApi.ts @@ -12,6 +12,8 @@ export interface UserFocusPreferences { reduceMotion: boolean; notificationIntensity: NotificationIntensity; defaultPresetId: DefaultPresetId; + defaultSceneId: string | null; + defaultSoundPresetId: string | null; } export type UpdateUserFocusPreferencesRequest = Partial; @@ -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 = { diff --git a/src/features/sound-preset/model/useSoundPlayback.ts b/src/features/sound-preset/model/useSoundPlayback.ts index 3c352b6..9693da3 100644 --- a/src/features/sound-preset/model/useSoundPlayback.ts +++ b/src/features/sound-preset/model/useSoundPlayback.ts @@ -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(null); + const isPlaybackUnlockedRef = useRef(false); + const requestedUrlRef = useRef(null); const [isReady, setReady] = useState(false); const [isPlaying, setPlaying] = useState(false); const [error, setError] = useState(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, }; }; diff --git a/src/shared/i18n/ko.ts b/src/shared/i18n/ko.ts index 10a84ad..3fc0708 100644 --- a/src/shared/i18n/ko.ts +++ b/src/shared/i18n/ko.ts @@ -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: '길게 눌러 나가기', diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 4b7b360..4cf687c 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -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(null); const lastSoundPlaybackErrorRef = useRef(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) {