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

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