Files
gods-eye-view/src/ui/sceneControls.js
T
Sameh Khamis b20c80e590 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.
2026-11-01 16:47:39 -08:00

284 lines
8.6 KiB
JavaScript

/** Own Scene panel listeners and presentation through project reads and actions. */
import { t, msg, onLocaleChange } from '../i18n/index.js';
import { sceneButtonsDisabled, scenePercent } from './scenePresentation.js';
import { useSceneStore } from './stores/scenes.js';
import { applicationPinia } from './vueRuntime.js';
export class SceneControls {
/**
* @param {object} options
* @param {object} [options.store] Scene store the panel views render.
*/
constructor({
read,
actions,
subscribe,
store = useSceneStore(applicationPinia()),
}) {
this.read = read;
this.actions = actions;
this.store = store;
this.destroyed = false;
this.actionGeneration = 0;
this.playbackKeyRemover = null;
// The panel views render the buttons, status, progress and runtime chip.
this.panel = {
running: false,
hasShots: false,
disabled: sceneButtonsDisabled(false, false),
status: '',
progress: 0,
runtime: '',
// The radio's PLAY means "listen"; this one plays the scene.
playLabel: t('PLAY', { context: 'scene' }),
};
// The SceneShotList view renders the rows and the panel its buttons;
// their actions come back here.
this.storeToken = store.attach({
select: (id) => {
this.run('selectShot', id);
if (this.destroyed) return;
store.selectShot(this.read().selectedShotId);
this.setButtons(this.read().running);
},
rename: (sceneId, shotId, title) =>
this.run('renameShot', sceneId, shotId, title),
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
remove: (sceneId, shotId) => this.deleteShot(sceneId, shotId),
selectScene: (sceneId) => this.run('selectScene', sceneId),
create: () => this.createScene(),
deleteScene: () => this.deleteSelectedScene(),
capture: () => this.run('capture'),
update: () => this.run('update'),
next: () => this.run('next'),
export: () => this.run('export'),
download: () => this.run('download'),
start: () => this.run('start', this.read().selectedSceneId),
stop: () => this.run('stop', msg('Stopped')),
importFile: (file) =>
this.run(this.actions.reviewImport ? 'reviewImport' : 'import', file),
});
this.presentPanel({});
// Scene and shot titles are shown translated; the select and the list
// show them again.
this.offLocale = onLocaleChange(() => {
this.presentPanel({ playLabel: t('PLAY', { context: 'scene' }) });
this.renderSceneSelect();
this.renderShotList();
});
if (subscribe)
this.unsubscribe = subscribe((notification) =>
this.present(notification),
);
else {
this.renderSceneSelect();
this.renderShotList();
}
if (!subscribe) {
this.updateStatus(t('Ready'));
this.setProgress(0);
this.setButtons(false);
}
}
/** Apply only the presentation affected by an action; selection keeps row identity. */
present({ state, change, initial }) {
if (this.destroyed) return;
const type = change?.type;
if (
initial ||
[
'scene-options-changed',
'scene-created',
'scene-deleted',
'project-imported',
].includes(type)
)
this.renderSceneSelect();
if (
initial ||
[
'shots-changed',
'scene-created',
'scene-deleted',
'shot-captured',
'shot-updated',
'shot-renamed',
'shot-deleted',
'project-imported',
].includes(type)
)
this.renderShotList();
if (type === 'selection-changed')
this.store.selectShot(state.selectedShotId);
if (
initial ||
[
'buttons-changed',
'selection-changed',
'scene-options-changed',
'scene-created',
'scene-deleted',
'shots-changed',
'shot-captured',
'shot-deleted',
'project-imported',
].includes(type)
)
this.setButtons(state.running);
if (initial || type === 'progress-changed')
this.setProgress(state.progress);
if (initial || type === 'status-changed') this.updateStatus(state.status);
if (initial || type === 'runtime-changed')
this.updateRuntime(state.runtime);
if (initial || type === 'playback-presentation')
this.setPlaybackActive(state.playbackActive);
if (initial || type === 'playback-keyboard')
this.setPlaybackKeyboardEnabled(state.keyboardEnabled);
if (type === 'project-exported') this.updateStatus(state.status);
if (type === 'shot-loaded') this.updateStatus(state.status);
if (type === 'run-event' && change.event === 'shot_start')
this.setButtons(state.running);
}
/** Merge `patch` into the panel and publish it. */
presentPanel(patch) {
if (this.destroyed) return;
Object.assign(this.panel, patch);
// A newer owner's panel is left alone.
if (this.store.owns(this.storeToken))
this.store.publishPanel({ ...this.panel });
}
run(action, ...args) {
if (this.destroyed) return;
const generation = ++this.actionGeneration;
const failed = () => {
if (!this.destroyed && generation === this.actionGeneration)
this.updateStatus(t('Scene action failed'));
};
try {
const result = this.actions[action](...args);
return result?.then ? Promise.resolve(result).catch(failed) : result;
} catch {
failed();
}
}
/** Publish the project to the SceneOptions and SceneShotList views. */
renderSceneSelect() {
if (!this.destroyed) this.store.publishOptions(this.read());
}
createScene() {
if (this.destroyed) return;
const name = window.prompt(
t('New scene name'),
t('Scene {n}', { n: this.read().scenes.length + 1 }),
);
if (name) this.run('create', name);
}
deleteSelectedScene() {
if (this.destroyed) return;
const state = this.read();
const scene = state.scenes.find(
(item) => item.id === state.selectedSceneId,
);
if (
scene &&
window.confirm(
t('Delete scene "{scene}" and all shots?', { scene: scene.title }),
)
)
this.run('deleteScene');
}
deleteShot(sceneId, shotId) {
if (this.destroyed) return;
const scene = this.read().scenes.find((item) => item.id === sceneId);
const shot = scene?.shots.find((item) => item.id === shotId);
if (
shot &&
window.confirm(
t('Delete shot "{shot}"?', { shot: shot.displayTitle || shot.title }),
)
)
this.run('deleteShot', sceneId, shotId);
}
renderShotList() {
if (!this.destroyed) this.store.publish(this.read());
}
setButtons(running) {
if (this.destroyed) return;
const state = this.read();
const scene = state.scenes?.find(
(item) => item.id === state.selectedSceneId,
);
const shots = scene?.shots ?? [];
this.presentPanel({
running: !!running,
hasShots: shots.length > 0,
disabled: sceneButtonsDisabled(running, state.hasRun, {
hasScene: !!scene,
// PLAY and NEXT run on into the following scenes.
playableShots: (state.scenes ?? []).reduce(
(total, item) => total + (item.shots?.length ?? 0),
0,
),
hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId),
}),
});
}
setProgress(progress) {
this.presentPanel({ progress: scenePercent(progress) });
}
updateStatus(text) {
this.presentPanel({ status: text });
}
updateRuntime(text) {
this.presentPanel({ runtime: text || '' });
}
setPlaybackActive(active) {
if (this.destroyed && active) return;
document.body.classList.toggle('scene-playback-mode', active);
}
setPlaybackKeyboardEnabled(enabled) {
if (!enabled) {
this.playbackKeyRemover?.();
this.playbackKeyRemover = null;
} else if (!this.destroyed && !this.playbackKeyRemover) {
const onKeyDown = (event) => {
if (!this.destroyed && event.key === 'Escape' && this.read().running)
this.run('stop', msg('Stopped (Esc)'));
};
document.addEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () =>
document.removeEventListener('keydown', onKeyDown);
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.actionGeneration++;
this.unsubscribe?.();
this.unsubscribe = null;
this.offLocale();
this.setPlaybackKeyboardEnabled(false);
// The panel keeps its last status and buttons, without shots or the
// runtime chip.
if (this.store.owns(this.storeToken))
this.store.publishPanel({ ...this.panel, hasShots: false, runtime: '' });
this.store.detach(this.storeToken);
this.setPlaybackActive(false);
}
}