feat(space): persist media selection and stabilize sound playback
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user