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 type { MediaManifest } from '../model/types';
|
||||||
import { copy } from '@/shared/i18n';
|
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 = {
|
export const mediaManifestApi = {
|
||||||
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
||||||
@@ -13,7 +38,7 @@ export const mediaManifestApi = {
|
|||||||
|
|
||||||
const response = await fetch(MEDIA_MANIFEST_URL, {
|
const response = await fetch(MEDIA_MANIFEST_URL, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'force-cache',
|
cache: MEDIA_MANIFEST_FETCH_CACHE,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||||
import {
|
import {
|
||||||
buildSceneAssetMap,
|
buildSceneAssetMap,
|
||||||
@@ -14,7 +14,7 @@ let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
|||||||
let manifestRequest: Promise<MediaManifest> | null = null;
|
let manifestRequest: Promise<MediaManifest> | null = null;
|
||||||
|
|
||||||
const readMediaManifest = async (signal?: AbortSignal) => {
|
const readMediaManifest = async (signal?: AbortSignal) => {
|
||||||
if (!process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL) {
|
if (!MEDIA_MANIFEST_URL) {
|
||||||
return manifestCache;
|
return manifestCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ const HUB_CURATION_ORDER = [
|
|||||||
'quiet-library',
|
'quiet-library',
|
||||||
'rain-window',
|
'rain-window',
|
||||||
'dawn-cafe',
|
'dawn-cafe',
|
||||||
'green-forest',
|
'forest',
|
||||||
'fireplace',
|
'fireplace',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
||||||
|
const SCENE_ID_ALIASES: Record<string, string> = {
|
||||||
|
'green-forest': 'forest',
|
||||||
|
};
|
||||||
|
|
||||||
export const SCENE_THEMES: SceneTheme[] = [
|
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%)',
|
'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,
|
name: copy.scenes[4].name,
|
||||||
description: copy.scenes[4].description,
|
description: copy.scenes[4].description,
|
||||||
tags: [...copy.scenes[4].tags],
|
tags: [...copy.scenes[4].tags],
|
||||||
recommendedSound: copy.scenes[4].recommendedSound,
|
recommendedSound: copy.scenes[4].recommendedSound,
|
||||||
recommendedSoundPresetId: 'rain-focus',
|
recommendedSoundPresetId: 'forest-birds',
|
||||||
recommendedTimerPresetId: '50-10',
|
recommendedTimerPresetId: '50-10',
|
||||||
recommendedTime: copy.scenes[4].recommendedTime,
|
recommendedTime: copy.scenes[4].recommendedTime,
|
||||||
vibeLabel: copy.scenes[4].vibeLabel,
|
vibeLabel: copy.scenes[4].vibeLabel,
|
||||||
@@ -236,7 +239,16 @@ export const SCENE_THEMES: SceneTheme[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const getSceneById = (id: string) => {
|
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) => {
|
export const getSceneCardPhotoUrl = (scene: SceneTheme) => {
|
||||||
@@ -280,7 +292,8 @@ export const getHubSceneSections = (
|
|||||||
recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
|
recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
|
||||||
) => {
|
) => {
|
||||||
const sceneById = new Map(scenes.map((scene) => [scene.id, scene] as const));
|
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 curatedScenes = HUB_CURATION_ORDER.map((id) => sceneById.get(id));
|
||||||
|
|
||||||
const recommendedScenes = uniqueBySceneId([
|
const recommendedScenes = uniqueBySceneId([
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface CompleteFocusSessionRequest {
|
|||||||
completedGoal?: string;
|
completedGoal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||||
|
sceneId?: string;
|
||||||
|
soundPresetId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const focusSessionApi = {
|
export const focusSessionApi = {
|
||||||
/**
|
/**
|
||||||
* Backend Codex:
|
* 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:
|
* Backend Codex:
|
||||||
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
|
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type FocusSessionPhase,
|
type FocusSessionPhase,
|
||||||
type FocusSessionState,
|
type FocusSessionState,
|
||||||
type StartFocusSessionRequest,
|
type StartFocusSessionRequest,
|
||||||
|
type UpdateCurrentFocusSessionSelectionRequest,
|
||||||
} from '../api/focusSessionApi';
|
} from '../api/focusSessionApi';
|
||||||
|
|
||||||
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
||||||
@@ -69,6 +70,7 @@ interface UseFocusSessionEngineResult {
|
|||||||
pauseSession: () => Promise<FocusSession | null>;
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
resumeSession: () => Promise<FocusSession | null>;
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
abandonSession: () => Promise<boolean>;
|
abandonSession: () => Promise<boolean>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -230,6 +232,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
|||||||
|
|
||||||
return applySession(session);
|
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) => {
|
completeSession: async (payload) => {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface UserFocusPreferences {
|
|||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
notificationIntensity: NotificationIntensity;
|
notificationIntensity: NotificationIntensity;
|
||||||
defaultPresetId: DefaultPresetId;
|
defaultPresetId: DefaultPresetId;
|
||||||
|
defaultSceneId: string | null;
|
||||||
|
defaultSoundPresetId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||||
@@ -20,6 +22,8 @@ export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
|||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||||
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
||||||
|
defaultSceneId: null,
|
||||||
|
defaultSoundPresetId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const preferencesApi = {
|
export const preferencesApi = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { SoundAssetManifestItem } from '@/entities/media';
|
import type { SoundAssetManifestItem } from '@/entities/media';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
@@ -16,6 +16,14 @@ const clampVolume = (value: number) => {
|
|||||||
return Math.min(1, Math.max(0, value / 100));
|
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 = ({
|
export const useSoundPlayback = ({
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
soundAsset,
|
soundAsset,
|
||||||
@@ -24,6 +32,8 @@ export const useSoundPlayback = ({
|
|||||||
shouldPlay,
|
shouldPlay,
|
||||||
}: UseSoundPlaybackOptions) => {
|
}: UseSoundPlaybackOptions) => {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const isPlaybackUnlockedRef = useRef(false);
|
||||||
|
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);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -44,7 +54,7 @@ export const useSoundPlayback = ({
|
|||||||
const audio = new window.Audio();
|
const audio = new window.Audio();
|
||||||
audio.loop = true;
|
audio.loop = true;
|
||||||
audio.preload = 'auto';
|
audio.preload = 'auto';
|
||||||
audio.crossOrigin = 'anonymous';
|
isPlaybackUnlockedRef.current = false;
|
||||||
|
|
||||||
const handleCanPlay = () => {
|
const handleCanPlay = () => {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@@ -65,6 +75,10 @@ export const useSoundPlayback = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
|
if (!requestedUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setReady(false);
|
setReady(false);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setError(copy.soundPlayback.loadFailed);
|
setError(copy.soundPlayback.loadFailed);
|
||||||
@@ -101,6 +115,51 @@ export const useSoundPlayback = ({
|
|||||||
audio.muted = isMuted;
|
audio.muted = isMuted;
|
||||||
}, [isMuted, masterVolume]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
|
|
||||||
@@ -111,7 +170,7 @@ export const useSoundPlayback = ({
|
|||||||
if (!activeUrl) {
|
if (!activeUrl) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute('src');
|
audio.removeAttribute('src');
|
||||||
audio.load();
|
requestedUrlRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +178,7 @@ export const useSoundPlayback = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestedUrlRef.current = activeUrl;
|
||||||
audio.src = activeUrl;
|
audio.src = activeUrl;
|
||||||
audio.load();
|
audio.load();
|
||||||
}, [activeUrl]);
|
}, [activeUrl]);
|
||||||
@@ -135,6 +195,11 @@ export const useSoundPlayback = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isPlaybackUnlockedRef.current) {
|
||||||
|
audio.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const playAudio = async () => {
|
const playAudio = async () => {
|
||||||
@@ -144,10 +209,10 @@ export const useSoundPlayback = ({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (playbackError) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setError(copy.soundPlayback.browserDeferred);
|
setError(resolvePlaybackErrorMessage(playbackError));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -164,5 +229,6 @@ export const useSoundPlayback = ({
|
|||||||
isReady,
|
isReady,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
error,
|
error,
|
||||||
|
unlockPlayback,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const ko = {
|
|||||||
soundPresets: [
|
soundPresets: [
|
||||||
{ id: 'deep-white', label: 'Deep White' },
|
{ id: 'deep-white', label: 'Deep White' },
|
||||||
{ id: 'rain-focus', label: 'Rain Focus' },
|
{ id: 'rain-focus', label: 'Rain Focus' },
|
||||||
|
{ id: 'forest-birds', label: 'Forest Birds' },
|
||||||
{ id: 'cafe-work', label: 'Cafe Work' },
|
{ id: 'cafe-work', label: 'Cafe Work' },
|
||||||
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
||||||
{ id: 'fireplace', label: 'Fireplace' },
|
{ id: 'fireplace', label: 'Fireplace' },
|
||||||
@@ -451,11 +452,11 @@ export const ko = {
|
|||||||
vibeLabel: '차분함',
|
vibeLabel: '차분함',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'green-forest',
|
id: 'forest',
|
||||||
name: '숲',
|
name: '숲',
|
||||||
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
||||||
tags: ['저자극', '움직임 적음'],
|
tags: ['저자극', '움직임 적음'],
|
||||||
recommendedSound: 'Forest Hush',
|
recommendedSound: 'Forest Birds',
|
||||||
recommendedTime: '오전',
|
recommendedTime: '오전',
|
||||||
vibeLabel: '맑음',
|
vibeLabel: '맑음',
|
||||||
},
|
},
|
||||||
@@ -704,6 +705,8 @@ export const ko = {
|
|||||||
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||||
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||||
nextGoalReady: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
|
nextGoalReady: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
|
||||||
|
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
||||||
|
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
|
||||||
},
|
},
|
||||||
exitHold: {
|
exitHold: {
|
||||||
holdToExitAriaLabel: '길게 눌러 나가기',
|
holdToExitAriaLabel: '길게 눌러 나가기',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
getSceneById,
|
getSceneById,
|
||||||
|
normalizeSceneId,
|
||||||
SCENE_THEMES,
|
SCENE_THEMES,
|
||||||
} from '@/entities/scene';
|
} from '@/entities/scene';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type TimerPreset,
|
type TimerPreset,
|
||||||
} from '@/entities/session';
|
} from '@/entities/session';
|
||||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||||
|
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
|
||||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||||
@@ -70,12 +72,14 @@ const readStoredWorkspaceSelection = (): StoredWorkspaceSelection => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveInitialSceneId = (sceneIdFromQuery: string | null, storedSceneId?: string) => {
|
const resolveInitialSceneId = (sceneIdFromQuery: string | null, storedSceneId?: string) => {
|
||||||
if (sceneIdFromQuery && getSceneById(sceneIdFromQuery)) {
|
const normalizedQuerySceneId = normalizeSceneId(sceneIdFromQuery);
|
||||||
return sceneIdFromQuery;
|
if (normalizedQuerySceneId && getSceneById(normalizedQuerySceneId)) {
|
||||||
|
return normalizedQuerySceneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedSceneId && getSceneById(storedSceneId)) {
|
const normalizedStoredSceneId = normalizeSceneId(storedSceneId);
|
||||||
return storedSceneId;
|
if (normalizedStoredSceneId && getSceneById(normalizedStoredSceneId)) {
|
||||||
|
return normalizedStoredSceneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return SCENE_THEMES[0].id;
|
return SCENE_THEMES[0].id;
|
||||||
@@ -204,6 +208,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
});
|
});
|
||||||
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
||||||
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
||||||
|
const didHydrateServerPreferencesRef = useRef(false);
|
||||||
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -224,6 +229,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
updateCurrentSelection,
|
||||||
completeSession,
|
completeSession,
|
||||||
abandonSession,
|
abandonSession,
|
||||||
} = useFocusSessionEngine();
|
} = useFocusSessionEngine();
|
||||||
@@ -279,6 +285,52 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [selectionOverride, setSelectedPresetId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const storedSelection = readStoredWorkspaceSelection();
|
const storedSelection = readStoredWorkspaceSelection();
|
||||||
const restoredSelectionOverride: SelectionOverride = {
|
const restoredSelectionOverride: SelectionOverride = {
|
||||||
@@ -287,7 +339,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
};
|
};
|
||||||
const restoredSceneId =
|
const restoredSceneId =
|
||||||
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
|
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
|
||||||
? storedSelection.sceneId
|
? normalizeSceneId(storedSelection.sceneId)
|
||||||
: null;
|
: null;
|
||||||
const restoredTimerLabel = !timerQuery
|
const restoredTimerLabel = !timerQuery
|
||||||
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
|
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
|
||||||
@@ -325,6 +377,50 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
};
|
};
|
||||||
}, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return;
|
return;
|
||||||
@@ -338,7 +434,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
? currentSession.soundPresetId
|
? currentSession.soundPresetId
|
||||||
: selectedPresetId;
|
: selectedPresetId;
|
||||||
const rafId = window.requestAnimationFrame(() => {
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
setSelectedSceneId(currentSession.sceneId);
|
setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId);
|
||||||
setSelectedTimerLabel(nextTimerLabel);
|
setSelectedTimerLabel(nextTimerLabel);
|
||||||
setSelectedPresetId(nextSoundPresetId);
|
setSelectedPresetId(nextSoundPresetId);
|
||||||
setGoalInput(currentSession.goal);
|
setGoalInput(currentSession.goal);
|
||||||
@@ -360,7 +456,16 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
|
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
|
||||||
}, [selectedScene, selectedSceneAsset]);
|
}, [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,
|
selectedPresetId,
|
||||||
soundAsset: soundAssetMap[selectedPresetId],
|
soundAsset: soundAssetMap[selectedPresetId],
|
||||||
masterVolume,
|
masterVolume,
|
||||||
@@ -369,8 +474,27 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSelectScene = (sceneId: string) => {
|
const handleSelectScene = (sceneId: string) => {
|
||||||
setSelectedSceneId(sceneId);
|
void (async () => {
|
||||||
applyRecommendedSelections(sceneId);
|
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) => {
|
const handleSelectTimer = (timerLabel: string, markOverride = false) => {
|
||||||
@@ -390,19 +514,29 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSound = (presetId: string, markOverride = false) => {
|
const handleSelectSound = (presetId: string, markOverride = false) => {
|
||||||
setSelectedPresetId(presetId);
|
void (async () => {
|
||||||
|
if (shouldPlaySound) {
|
||||||
if (!markOverride) {
|
await unlockPlayback(resolveSoundPlaybackUrl(presetId));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectionOverride((current) => {
|
|
||||||
if (current.sound) {
|
|
||||||
return current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
const handleGoalChipSelect = (chip: GoalChip) => {
|
||||||
@@ -449,7 +583,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startedSession = await startSession({
|
const startedSession = await startSession({
|
||||||
sceneId: selectedSceneId,
|
sceneId: selectedScene.id,
|
||||||
goal: trimmedGoal,
|
goal: trimmedGoal,
|
||||||
timerPresetId,
|
timerPresetId,
|
||||||
soundPresetId: selectedPresetId,
|
soundPresetId: selectedPresetId,
|
||||||
@@ -480,6 +614,8 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
|
||||||
|
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
await startFocusFlow();
|
await startFocusFlow();
|
||||||
return;
|
return;
|
||||||
@@ -604,15 +740,15 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
|
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
sceneId: selectedSceneId,
|
sceneId: selectedScene.id,
|
||||||
timerPresetId,
|
timerPresetId,
|
||||||
soundPresetId: selectedPresetId,
|
soundPresetId: selectedPresetId,
|
||||||
goal: normalizedGoal,
|
goal: normalizedGoal,
|
||||||
override: selectionOverride,
|
override: selectionOverride,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [goalInput, hasHydratedSelection, resumeGoal, selectedSceneId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
|
}, [goalInput, hasHydratedSelection, resumeGoal, selectedScene.id, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocusMode || !queuedFocusStatusMessageRef.current) {
|
if (!isFocusMode || !queuedFocusStatusMessageRef.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user