mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Switch language in place, without a reload
Choosing a language in the picker now applies it at once. The choice is stored and ?lang is kept in step through replaceState, and the picker keeps focus. Code that writes translated text itself re-presents it on a switch: onLocaleChange() lets an owner listen, removed at teardown, and status writers hand over a function of the locale (voice cards, scene director, SDR, draw hints, imagery split, map sources, Bhote Koshi media) so the words are rebuilt rather than re-sent. The HUD, cockpit, radio, CCTV, launch, awareness and credit texts follow the same hook, and applyDocumentTranslations can be applied again without undoing text code wrote since. The unsaved-work registry and persistLocaleAndReload existed only to guard the reload, so they go, with their catalog entries.
This commit is contained in:
1 parent
ba20bc13d8
commit
b20c80e590
87 files changed
+1194
-777
No files matched your search
+70
-40
@@ -29,7 +29,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t, tn, msg } from '../i18n/index.js';
|
||||
import { t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
@@ -176,17 +176,21 @@ export class SceneDirector {
|
||||
/** @type {string|null} Scene whose layer state most recently landed. */
|
||||
this._loadedSceneId = null;
|
||||
|
||||
const readError = this._storageReadError;
|
||||
this._presentation = {
|
||||
status: this._storageReadError
|
||||
? t(
|
||||
'Saved project could not be read; storage preserved. Import a valid file to resume saving.',
|
||||
)
|
||||
: t('Ready'),
|
||||
status: '',
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
playbackActive: false,
|
||||
keyboardEnabled: false,
|
||||
};
|
||||
this._setStatus(() =>
|
||||
readError
|
||||
? t(
|
||||
'Saved project could not be read; storage preserved. Import a valid file to resume saving.',
|
||||
)
|
||||
: t('Ready'),
|
||||
);
|
||||
this._state = createStateChannel(() => ({
|
||||
...this.getPlaybackStatus(),
|
||||
...this._presentation,
|
||||
@@ -212,6 +216,12 @@ export class SceneDirector {
|
||||
}
|
||||
}
|
||||
this._initUI();
|
||||
// A status the director wrote itself follows the language; one it was
|
||||
// handed (an error message) stays as it is.
|
||||
this._offLocale = onLocaleChange(() => {
|
||||
if (typeof this._statusText === 'function')
|
||||
this._updateStatus(this._statusText);
|
||||
});
|
||||
this._visibilityUnsubscribe =
|
||||
this.dataManager.subscribeVisibilityRequests?.((change) => {
|
||||
if (
|
||||
@@ -247,6 +257,7 @@ export class SceneDirector {
|
||||
this._interactions?.destroy();
|
||||
this._dataPacks?.destroy();
|
||||
this._controls?.destroy();
|
||||
this._offLocale?.();
|
||||
this._state.destroy();
|
||||
this._destroyPromise = Promise.resolve().then(async () => {
|
||||
this.stopScene(msg('Stopped'));
|
||||
@@ -324,7 +335,7 @@ export class SceneDirector {
|
||||
/** Persist the current project state to localStorage with an updated timestamp. */
|
||||
_saveProject() {
|
||||
if (this._storageReadError) {
|
||||
this._toastStorageError(
|
||||
this._toastStorageError(() =>
|
||||
t(
|
||||
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
|
||||
),
|
||||
@@ -352,22 +363,26 @@ export class SceneDirector {
|
||||
);
|
||||
this._toastStorageError(
|
||||
invalid
|
||||
? t('Scene not saved — {reason}', {
|
||||
reason: sceneSaveFailureReason(e),
|
||||
})
|
||||
? () =>
|
||||
t('Scene not saved — {reason}', {
|
||||
reason: sceneSaveFailureReason(e),
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Surface a "scene not saved" notice via the global toast + scene status line. */
|
||||
/**
|
||||
* Surface a "scene not saved" notice via the global toast + scene status
|
||||
* line. `message` words it, so the status line follows the locale.
|
||||
*/
|
||||
_toastStorageError(
|
||||
message = t('Scene not saved — browser storage unavailable'),
|
||||
message = () => t('Scene not saved — browser storage unavailable'),
|
||||
) {
|
||||
this._updateStatus(message);
|
||||
this._storageToast ??= createToast({ durationMs: 2600 });
|
||||
this._storageToast.show(message);
|
||||
this._storageToast.show(message());
|
||||
}
|
||||
|
||||
/** Immutable playback snapshots and completed editing actions. */
|
||||
@@ -381,10 +396,12 @@ export class SceneDirector {
|
||||
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
});
|
||||
this._setStatus(() =>
|
||||
t('Loaded: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
this._publish({
|
||||
type,
|
||||
sceneId: scene.id,
|
||||
@@ -786,7 +803,7 @@ export class SceneDirector {
|
||||
(shot, index) => shot.title === requiredShotTitles[index],
|
||||
));
|
||||
if (!inventoryMatches) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Cannot update {scene}: shot inventory changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
@@ -819,7 +836,7 @@ export class SceneDirector {
|
||||
(beatId, index) => beatId === requiredSourcePackBeatIds[index],
|
||||
);
|
||||
if (!sourcePackMatches) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Cannot update {scene}: evidence beats changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
@@ -844,7 +861,7 @@ export class SceneDirector {
|
||||
shot: resolvePatchedShot(patch),
|
||||
}));
|
||||
if (resolvedPatches.some(({ shot }) => !shot)) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Cannot update {scene}: shot bindings are incomplete', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
@@ -925,7 +942,7 @@ export class SceneDirector {
|
||||
this._renderShotList();
|
||||
}
|
||||
if (announce && saved) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
marker
|
||||
? tn(
|
||||
patchedShotCount,
|
||||
@@ -960,7 +977,7 @@ export class SceneDirector {
|
||||
|
||||
const camera = this.styleManager.getCameraState();
|
||||
if (!camera) {
|
||||
this._updateStatus(t('Cannot capture shot: camera not ready'));
|
||||
this._updateStatus(() => t('Cannot capture shot: camera not ready'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -983,7 +1000,7 @@ export class SceneDirector {
|
||||
const saved = this._saveProject();
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
if (saved) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
@@ -1002,7 +1019,7 @@ export class SceneDirector {
|
||||
|
||||
const shot = scene.shots.find((item) => item.id === this._selectedShotId);
|
||||
if (!shot) {
|
||||
this._updateStatus(t('Select a shot first'));
|
||||
this._updateStatus(() => t('Select a shot first'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1018,7 +1035,7 @@ export class SceneDirector {
|
||||
const saved = this._saveProject();
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
if (saved) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
@@ -1130,7 +1147,7 @@ export class SceneDirector {
|
||||
const released = await this._releaseSceneLayers(previousScene, token);
|
||||
if (!released || token.cancelled) {
|
||||
if (!token.cancelled)
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Could not leave scene: {scene}', {
|
||||
scene: displaySceneTitle(previousScene),
|
||||
}),
|
||||
@@ -1184,7 +1201,7 @@ export class SceneDirector {
|
||||
seeking: true,
|
||||
});
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
@@ -1634,7 +1651,7 @@ export class SceneDirector {
|
||||
running: false,
|
||||
seeking: true,
|
||||
});
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
@@ -1721,7 +1738,7 @@ export class SceneDirector {
|
||||
this._claimingCamera = false;
|
||||
}
|
||||
if (claimed === false) {
|
||||
this._updateStatus(t('Camera unavailable — exit cockpit first'));
|
||||
this._updateStatus(() => t('Camera unavailable — exit cockpit first'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1856,7 +1873,7 @@ export class SceneDirector {
|
||||
if (!queue.length) return { started: false, reason: 'scene-complete' };
|
||||
}
|
||||
if (!queue.length) {
|
||||
this._updateStatus(t('No shots to run'));
|
||||
this._updateStatus(() => t('No shots to run'));
|
||||
return { started: false, reason: 'no-shots' };
|
||||
}
|
||||
|
||||
@@ -1941,7 +1958,7 @@ export class SceneDirector {
|
||||
releaseOnFinish: preview,
|
||||
});
|
||||
} catch (error) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Error: {message}', {
|
||||
message: error.message || 'run failed',
|
||||
}),
|
||||
@@ -2010,7 +2027,7 @@ export class SceneDirector {
|
||||
if (!this._running || !this._runToken) return;
|
||||
this._runToken.cancelled = true;
|
||||
this._runAbort?.abort();
|
||||
this._updateStatus(t(reason));
|
||||
this._updateStatus(() => t(reason));
|
||||
this._logEvent('scene_stopped', { reason });
|
||||
}
|
||||
|
||||
@@ -2066,7 +2083,7 @@ export class SceneDirector {
|
||||
}
|
||||
if (action.type === 'shot') {
|
||||
if (this._interactionTransitions >= 64) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Scene transition limit reached — load a shot to reset'),
|
||||
);
|
||||
return false;
|
||||
@@ -2110,7 +2127,9 @@ export class SceneDirector {
|
||||
try {
|
||||
payload = stringifySceneDocument(this._project);
|
||||
} catch (error) {
|
||||
this._updateStatus(t('Export failed: {error}', { error: error.message }));
|
||||
this._updateStatus(() =>
|
||||
t('Export failed: {error}', { error: error.message }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Trigger a browser download via a temporary anchor element
|
||||
@@ -2123,7 +2142,7 @@ export class SceneDirector {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this._presentation.status = t('Project exported');
|
||||
this._setStatus(() => t('Project exported'));
|
||||
this._publish({ type: 'project-exported', project: this._project });
|
||||
}
|
||||
|
||||
@@ -2183,7 +2202,8 @@ export class SceneDirector {
|
||||
this._loadedSceneId = null;
|
||||
const saved = this._saveProject();
|
||||
this._publish({ type: 'project-imported', project });
|
||||
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||
if (saved)
|
||||
this._updateStatus(() => t('Imported {file}', { file: file.name }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -2192,7 +2212,7 @@ export class SceneDirector {
|
||||
generation !== this._importGeneration
|
||||
)
|
||||
return false;
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
error instanceof SceneDocumentError
|
||||
? t('Import failed: {error}', { error: error.message })
|
||||
: t('Import failed (could not read JSON file)'),
|
||||
@@ -2302,7 +2322,7 @@ export class SceneDirector {
|
||||
}
|
||||
|
||||
if (refused.length) {
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Layers refused: {layers}', { layers: refused.join(', ') }),
|
||||
);
|
||||
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
|
||||
@@ -2394,7 +2414,7 @@ export class SceneDirector {
|
||||
`[Scenes] Could not exit ${mode}:`,
|
||||
result.error || 'unknown reason',
|
||||
);
|
||||
this._updateStatus(
|
||||
this._updateStatus(() =>
|
||||
t('Could not exit {mode} — scene layers may be refused', { mode }),
|
||||
);
|
||||
this._logEvent('context_mode_exit_failed', {
|
||||
@@ -2627,10 +2647,20 @@ export class SceneDirector {
|
||||
* @param {string} text
|
||||
*/
|
||||
_updateStatus(text) {
|
||||
this._presentation.status = text;
|
||||
this._setStatus(text);
|
||||
this._publish({ type: 'status-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the status line without publishing. A function is called again when
|
||||
* the locale changes, so its text follows the language.
|
||||
* @param {string|(() => string)} text
|
||||
*/
|
||||
_setStatus(text) {
|
||||
this._statusText = text;
|
||||
this._presentation.status = typeof text === 'function' ? text() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the runtime label (scene/shot name) and toggle its active class.
|
||||
* @param {string} text - Empty string hides the label
|
||||
|
||||
@@ -1930,7 +1930,7 @@ test('invalid authored edits cannot persist an unreadable project over a good sa
|
||||
try {
|
||||
const before = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
let notice;
|
||||
director._toastStorageError = (message) => { notice = message; };
|
||||
director._toastStorageError = (message) => { notice = message(); };
|
||||
director._project.scenes[0].shots[0].camera.lat = 91;
|
||||
director._saveProject();
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before);
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
director._selectedShotId = shot.id;
|
||||
director._renderSceneSelect();
|
||||
director._renderShotList();
|
||||
director._updateStatus(
|
||||
director._updateStatus(() =>
|
||||
t('Running {index}/{total}: {scene} / {shot}', {
|
||||
index: index + 1,
|
||||
total,
|
||||
@@ -79,7 +79,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
},
|
||||
complete() {
|
||||
director._setProgress(1);
|
||||
director._updateStatus(t('Scene run complete'));
|
||||
director._updateStatus(() => t('Scene run complete'));
|
||||
director._logEvent('scene_run_complete', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,7 +94,9 @@ test('the Scene status line shows an operator-typed shot title as typed', async
|
||||
const director = {
|
||||
_renderSceneSelect() {},
|
||||
_renderShotList() {},
|
||||
_updateStatus: (text) => statuses.push(text),
|
||||
// The director words its status through a function (it follows the locale).
|
||||
_updateStatus: (text) =>
|
||||
statuses.push(typeof text === 'function' ? text() : text),
|
||||
_logEvent() {},
|
||||
};
|
||||
const adapter = createScenePlaybackAdapter(director, 4);
|
||||
|
||||
@@ -196,7 +196,7 @@ export function createSceneSharing(director) {
|
||||
const scene = director._getSelectedScene(),
|
||||
shot = scene?.shots.find((s) => s.id === director._selectedShotId);
|
||||
if (!scene || !shot) {
|
||||
director._updateStatus(t('Select a scene and shot first'));
|
||||
director._updateStatus(() => t('Select a scene and shot first'));
|
||||
return;
|
||||
}
|
||||
const owner = open(t('Edit scene details'));
|
||||
@@ -340,7 +340,7 @@ export function createSceneSharing(director) {
|
||||
director._selectedSceneId,
|
||||
);
|
||||
} catch {
|
||||
director._updateStatus(t('Select a scene first'));
|
||||
director._updateStatus(() => t('Select a scene first'));
|
||||
return;
|
||||
}
|
||||
const owner = open(t('Share selected scene'));
|
||||
|
||||
Reference in new issue
Block a user