feat(space): persist media selection and stabilize sound playback

This commit is contained in:
2026-03-10 17:36:10 +09:00
parent c47f60163d
commit 9811134d8a
9 changed files with 321 additions and 40 deletions

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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([

View File

@@ -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:
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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,
};
};

View File

@@ -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: '길게 눌러 나가기',

View File

@@ -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) {