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

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