mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: separate Scene controls from playback and project operations
This commit is contained in:
1 parent
8b5ad67dcb
commit
cd72feb4fe
12 files changed
+566
-222
No files matched your search
@@ -0,0 +1,116 @@
|
||||
/** Own Scene panel listeners and presentation through project reads and actions. */
|
||||
import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js';
|
||||
|
||||
export class SceneControls {
|
||||
constructor({ read, actions, 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;
|
||||
this.renderSceneSelect();
|
||||
this.renderShotList();
|
||||
this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value));
|
||||
for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) {
|
||||
this.listen(elements[element], 'click', () => this.run(action));
|
||||
}
|
||||
this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId));
|
||||
this.listen(elements.stop, 'click', () => this.run('stop', '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('import', file);
|
||||
if (!this.destroyed) elements.file.value = '';
|
||||
});
|
||||
this.updateStatus('Ready');
|
||||
this.setProgress(0);
|
||||
this.setButtons(false);
|
||||
}
|
||||
|
||||
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('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());
|
||||
}
|
||||
|
||||
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),
|
||||
rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title),
|
||||
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
|
||||
remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId),
|
||||
});
|
||||
}
|
||||
|
||||
setButtons(running) {
|
||||
if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun);
|
||||
}
|
||||
|
||||
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', 'Stopped (Esc)');
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.actionGeneration++;
|
||||
this.setPlaybackKeyboardEnabled(false);
|
||||
for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove();
|
||||
if (this.elements.shots) this.elements.shots.textContent = '';
|
||||
presentSceneRuntime(this.elements.runtime, '');
|
||||
this.setPlaybackActive(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SceneControls } from './sceneControls.js';
|
||||
|
||||
function fixture() {
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.children = [];
|
||||
this.style = {};
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
|
||||
contains: (name) => classes.has(name),
|
||||
};
|
||||
}
|
||||
appendChild(element) { this.children.push(element); }
|
||||
set textContent(value) { this._text = value; this.children = []; }
|
||||
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
|
||||
click() { this.dispatchEvent(new Event('click')); }
|
||||
}
|
||||
const saved = { document: globalThis.document, window: globalThis.window };
|
||||
const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() });
|
||||
globalThis.document = document;
|
||||
globalThis.window = { prompt: () => 'Renamed' };
|
||||
const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()]));
|
||||
const state = {
|
||||
scenes: [{ id: 'scene-a', title: '<Scene A>', shots: [{ id: 'shot-a', title: '<Shot A>', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }],
|
||||
selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false,
|
||||
};
|
||||
const calls = [];
|
||||
const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }]));
|
||||
const owner = new SceneControls({ read: () => state, actions, elements });
|
||||
return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } };
|
||||
}
|
||||
|
||||
test('Scene controls render the supplied selection and running/download states', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.equal(f.elements.select.children[0].textContent, '<Scene A>');
|
||||
assert.equal(f.elements.select.value, 'scene-a');
|
||||
assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, '<Shot A>');
|
||||
assert.equal(f.elements.stop.disabled, true);
|
||||
assert.equal(f.elements.download.disabled, true);
|
||||
f.state.hasRun = true;
|
||||
f.owner.setButtons(true);
|
||||
assert.equal(f.elements.start.disabled, true);
|
||||
assert.equal(f.elements.capture.disabled, true);
|
||||
assert.equal(f.elements.stop.disabled, false);
|
||||
assert.equal(f.elements.download.disabled, false);
|
||||
assert.equal(f.elements.panel.classList.contains('running'), true);
|
||||
f.owner.setProgress(2);
|
||||
assert.equal(f.elements.progress.style.width, '100%');
|
||||
f.owner.setProgress(-1);
|
||||
assert.equal(f.elements.progress.textContent, '0%');
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const row = f.elements.shots.children[0];
|
||||
const label = row.children[0].children[0];
|
||||
const load = row.children[0].children[1].children[0];
|
||||
f.elements.capture.click();
|
||||
f.elements.start.click();
|
||||
label.click();
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
load.click();
|
||||
assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]);
|
||||
f.owner.renderShotList();
|
||||
label.click();
|
||||
load.click();
|
||||
assert.equal(f.calls.length, 5);
|
||||
f.elements.shots.children[0].children[0].children[1].children[0].click();
|
||||
assert.equal(f.calls.length, 6);
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('Scene Escape and recording presentation follow playback and release on destruction', () => {
|
||||
const f = fixture();
|
||||
const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' }));
|
||||
try {
|
||||
escape();
|
||||
assert.deepEqual(f.calls, []);
|
||||
f.state.running = true;
|
||||
f.owner.setPlaybackActive(true);
|
||||
f.owner.setPlaybackKeyboardEnabled(true);
|
||||
f.owner.updateRuntime('Scene A / Shot A');
|
||||
escape();
|
||||
assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]);
|
||||
f.owner.destroy();
|
||||
f.owner.destroy();
|
||||
f.owner.setPlaybackKeyboardEnabled(true);
|
||||
escape();
|
||||
f.elements.capture.click();
|
||||
f.owner.run('capture');
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.document.body.classList.contains('scene-playback-mode'), false);
|
||||
assert.equal(f.elements.runtime.textContent, '');
|
||||
assert.equal(f.owner.removers.length, 0);
|
||||
assert.equal(f.owner.rowRemovers.length, 0);
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('a file import finishing after disposal cannot clear the retained file control', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let finish;
|
||||
f.actions.import = () => new Promise((resolve) => { finish = resolve; });
|
||||
f.elements.file.files = [{ name: 'project.json' }];
|
||||
f.elements.file.value = 'project.json';
|
||||
f.elements.file.dispatchEvent(new Event('change'));
|
||||
f.owner.destroy();
|
||||
finish();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.elements.file.value, 'project.json');
|
||||
assert.equal(f.elements.status.textContent, 'Ready');
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('Scene action failures are visible only while that action still owns presentation', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let reject;
|
||||
f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; });
|
||||
let pending = f.owner.run('load', 'scene-a', 'shot-a');
|
||||
reject(new Error('unavailable'));
|
||||
await pending;
|
||||
assert.equal(f.elements.status.textContent, 'Scene action failed');
|
||||
pending = f.owner.run('load', 'scene-a', 'shot-a');
|
||||
f.owner.run('selectShot', 'shot-a');
|
||||
f.owner.updateStatus('Current selection');
|
||||
reject(new Error('obsolete'));
|
||||
await pending;
|
||||
assert.equal(f.elements.status.textContent, 'Current selection');
|
||||
pending = f.owner.run('load', 'scene-a', 'shot-a');
|
||||
f.owner.destroy();
|
||||
reject(new Error('disposed'));
|
||||
await pending;
|
||||
assert.equal(f.elements.status.textContent, 'Current selection');
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
export function sceneElements(root = document) {
|
||||
const ids = {
|
||||
panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn',
|
||||
delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn',
|
||||
shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn',
|
||||
next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn',
|
||||
file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status',
|
||||
progress: 'scene-progress-fill', runtime: 'scene-runtime',
|
||||
};
|
||||
return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]));
|
||||
}
|
||||
|
||||
export function renderSceneOptions(element, { scenes, selectedSceneId }) {
|
||||
if (!element) return;
|
||||
element.textContent = '';
|
||||
for (const scene of scenes) {
|
||||
const option = document.createElement('option');
|
||||
option.value = scene.id;
|
||||
option.textContent = scene.title;
|
||||
element.appendChild(option);
|
||||
}
|
||||
if (selectedSceneId) element.value = selectedSceneId;
|
||||
}
|
||||
|
||||
export function renderSceneShots(element, state, { listen, select, rename, load, remove }) {
|
||||
if (!element) return;
|
||||
const scene = state.scenes.find((item) => item.id === state.selectedSceneId);
|
||||
element.textContent = '';
|
||||
if (!scene || scene.shots.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scene-shot-empty';
|
||||
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
|
||||
element.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const shot of scene.shots) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'scene-shot-row';
|
||||
row.classList.toggle('active', shot.id === state.selectedShotId);
|
||||
const top = document.createElement('div');
|
||||
top.className = 'scene-shot-top';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
const title = window.prompt('Shot title', shot.title);
|
||||
if (title) rename(scene.id, shot.id, title);
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = 'LOAD';
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = 'DEL';
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
top.appendChild(label);
|
||||
top.appendChild(actions);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'scene-shot-meta';
|
||||
const mode = shot.visual?.detection?.mode || 'OFF';
|
||||
const style = shot.visual?.style || 'normal';
|
||||
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function presentSceneButtons(elements, running, hasRun) {
|
||||
for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) {
|
||||
if (elements[name]) elements[name].disabled = running;
|
||||
}
|
||||
if (elements.stop) elements.stop.disabled = !running;
|
||||
if (elements.download) elements.download.disabled = !hasRun;
|
||||
elements.panel?.classList.toggle('running', running);
|
||||
}
|
||||
|
||||
export function presentSceneProgress(element, progress) {
|
||||
if (!element) return;
|
||||
const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100);
|
||||
element.style.width = `${percentage}%`;
|
||||
element.textContent = `${percentage}%`;
|
||||
}
|
||||
|
||||
export function presentSceneRuntime(element, text) {
|
||||
if (!element) return;
|
||||
element.textContent = text;
|
||||
element.classList.toggle('active', !!text);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SceneControls } from './sceneControls.js';
|
||||
Reference in new issue
Block a user