Files
gods-eye-view/src/ui/sceneControls.js
T
Sameh Khamis ec4c55e78c feat(scenes): render the Scene panel's controls from the store
SceneControls no longer takes element handles or writes the Scene panel's
button states, PLAY label, status line, progress bar or runtime chip: it
presents them to the gev-scenes store, and the panel's buttons, scene
select and IMPORT chooser call its actions there. The chooser opens only
while an owner presents the panel and is cleared once its import settles.
On destroy the panel keeps its last status and buttons, without shots or
the runtime chip, as before.
2026-10-31 23:04:31 -07:00

276 lines
8.3 KiB
JavaScript

/** Own Scene panel listeners and presentation through project reads and actions. */
import { t, msg } 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({});
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.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);
}
}