mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
- PLAY and NEXT continue into the following scenes, so they stay enabled while any scene in the project has a shot, not only the selected one. - Renaming a shot drops the number and ellipsis so the editor is no longer hidden; qa-scene-controls checks a long rename stays visible. - PLAY, NEXT, EDIT DETAILS and SHARE SCENE tooltips describe what the buttons do, translated in every shipped language. - The primary actions and the file row keep a hover state. - Docs: the EXPORT rename in DIRECTOR-SHARING and the playback button states in CURRENT-STATE are accurate. - Test the voice "authoring" alias through set_panel_open.
284 lines
8.7 KiB
JavaScript
284 lines
8.7 KiB
JavaScript
/** Own Scene panel listeners and presentation through project reads and actions. */
|
|
import { t, msg } from '../i18n/index.js';
|
|
import {
|
|
sceneElements,
|
|
renderSceneOptions,
|
|
renderSceneShots,
|
|
presentSceneSelection,
|
|
presentSceneButtons,
|
|
presentSceneProgress,
|
|
presentSceneRuntime,
|
|
} from './scenePresentation.js';
|
|
|
|
export class SceneControls {
|
|
constructor({ read, actions, subscribe, elements = sceneElements() }) {
|
|
this.read = read;
|
|
this.actions = actions;
|
|
this.elements = elements;
|
|
this.destroyed = false;
|
|
this.actionGeneration = 0;
|
|
this.removers = [];
|
|
this.rowRemovers = [];
|
|
this.playbackKeyRemover = null;
|
|
if (!elements.select) return;
|
|
// The radio's PLAY means "listen"; this one plays the scene.
|
|
const playLabel = t('PLAY', { context: 'scene' });
|
|
if (elements.start) elements.start.textContent = playLabel;
|
|
if (subscribe)
|
|
this.unsubscribe = subscribe((notification) =>
|
|
this.present(notification),
|
|
);
|
|
else {
|
|
this.renderSceneSelect();
|
|
this.renderShotList();
|
|
}
|
|
this.listen(elements.select, 'change', () =>
|
|
this.run('selectScene', elements.select.value),
|
|
);
|
|
for (const [element, action] of Object.entries({
|
|
capture: 'capture',
|
|
update: 'update',
|
|
next: 'next',
|
|
export: 'export',
|
|
download: 'download',
|
|
})) {
|
|
this.listen(elements[element], 'click', () => this.run(action));
|
|
}
|
|
this.listen(elements.new, 'click', () => this.createScene());
|
|
this.listen(elements.delete, 'click', () => this.deleteSelectedScene());
|
|
this.listen(elements.start, 'click', () =>
|
|
this.run('start', this.read().selectedSceneId),
|
|
);
|
|
this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped')));
|
|
this.listen(elements.import, 'click', () => elements.file?.click());
|
|
this.listen(elements.file, 'change', async () => {
|
|
const file = elements.file?.files?.[0];
|
|
if (!file) return;
|
|
await this.run(
|
|
this.actions.reviewImport ? 'reviewImport' : 'import',
|
|
file,
|
|
);
|
|
if (!this.destroyed) elements.file.value = '';
|
|
});
|
|
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')
|
|
presentSceneSelection(this.elements.shots, 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);
|
|
}
|
|
|
|
listen(target, type, callback, removers = this.removers) {
|
|
if (!target) return;
|
|
const listener = (event) => {
|
|
if (!this.destroyed) return callback(event);
|
|
};
|
|
target.addEventListener(type, listener);
|
|
removers.push(() => target.removeEventListener(type, listener));
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
renderSceneSelect() {
|
|
if (!this.destroyed) renderSceneOptions(this.elements.select, 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) return;
|
|
for (const remove of this.rowRemovers.splice(0)) remove();
|
|
renderSceneShots(this.elements.shots, this.read(), {
|
|
listen: (element, type, callback) =>
|
|
this.listen(element, type, callback, this.rowRemovers),
|
|
select: (id) => {
|
|
this.run('selectShot', id);
|
|
if (this.destroyed) return;
|
|
presentSceneSelection(this.elements.shots, 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),
|
|
});
|
|
}
|
|
|
|
setButtons(running) {
|
|
if (this.destroyed) return;
|
|
const state = this.read();
|
|
const scene = state.scenes?.find(
|
|
(item) => item.id === state.selectedSceneId,
|
|
);
|
|
const shots = scene?.shots ?? [];
|
|
presentSceneButtons(this.elements, 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),
|
|
});
|
|
this.elements.panel?.classList.toggle('has-shots', shots.length > 0);
|
|
}
|
|
|
|
setProgress(progress) {
|
|
if (!this.destroyed) presentSceneProgress(this.elements.progress, progress);
|
|
}
|
|
|
|
updateStatus(text) {
|
|
if (!this.destroyed && this.elements.status)
|
|
this.elements.status.textContent = text;
|
|
}
|
|
|
|
updateRuntime(text) {
|
|
if (!this.destroyed) presentSceneRuntime(this.elements.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);
|
|
for (const remove of [
|
|
...this.removers.splice(0),
|
|
...this.rowRemovers.splice(0),
|
|
])
|
|
remove();
|
|
if (this.elements.shots) this.elements.shots.textContent = '';
|
|
this.elements.panel?.classList.toggle('has-shots', false);
|
|
presentSceneRuntime(this.elements.runtime, '');
|
|
this.setPlaybackActive(false);
|
|
}
|
|
}
|