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