mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: drive share, location and scene controls through product state
This commit is contained in:
1 parent
21a0d0c74e
commit
45d97376d0
21 files changed
+606
-96
No files matched your search
+54
-21
@@ -1,3 +1,4 @@
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { setSplitFlapText } from '../splitFlap.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { RecordingControls } from './recordingControls.js';
|
||||
@@ -537,6 +538,14 @@ export class StyleManager {
|
||||
]),
|
||||
);
|
||||
});
|
||||
this._shareState = createStateChannel(() => this._readShareState());
|
||||
this._shareState.subscribe(({ state }) => {
|
||||
this.shareLinkManager.onToggleChange(state.bloomEnabled, state.sharpenEnabled, state.options);
|
||||
}, { emitCurrent: false });
|
||||
this._locationState = createStateChannel(() => this._locationLookup?.getState() || null);
|
||||
this._locationState.subscribe(({ state, change }) => {
|
||||
this._handleLocationSearchState(state, change);
|
||||
}, { emitCurrent: false });
|
||||
// Parse before panel chrome initializes so every valid share URL starts
|
||||
// from deterministic markup defaults instead of recipient-local panel
|
||||
// preferences. Encoded panel fields are applied after all panels exist.
|
||||
@@ -1893,8 +1902,17 @@ export class StyleManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Current shareable visual preferences; subscriptions include an initial snapshot. */
|
||||
subscribeShareState(listener, options) {
|
||||
return this._shareState.subscribe(listener, options);
|
||||
}
|
||||
|
||||
_syncShareState() {
|
||||
if (this._disposed) return;
|
||||
this._shareState.publish({ type: 'settings-changed' });
|
||||
}
|
||||
|
||||
_readShareState() {
|
||||
const {
|
||||
getDetectionTuning,
|
||||
isScopeMaskEnabled,
|
||||
@@ -1902,10 +1920,10 @@ export class StyleManager {
|
||||
getScopeTerminusOverride,
|
||||
} = this.services;
|
||||
const detection = this._shareableDetectionState();
|
||||
this.shareLinkManager.onToggleChange(
|
||||
this.bloomEnabled,
|
||||
this.sharpenEnabled,
|
||||
{
|
||||
return {
|
||||
bloomEnabled: this.bloomEnabled,
|
||||
sharpenEnabled: this.sharpenEnabled,
|
||||
options: {
|
||||
bloomIntensity: this._getBloomIntensity(),
|
||||
bloomVersion: BLOOM_SCALE_VERSION,
|
||||
sharpenIntensity: parseInt(this._sharpenSlider?.value || '49', 10),
|
||||
@@ -1929,7 +1947,7 @@ export class StyleManager {
|
||||
: Math.round(getScopeTerminusOverride() * 100),
|
||||
mapStack: this.mapStackController?.getActiveId?.() || 'photoreal',
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4488,6 +4506,28 @@ export class StyleManager {
|
||||
|
||||
// ── Location Bar ─────────────────────────────
|
||||
|
||||
/** Subscribe to the current location lookup, including replacements of its control owner. */
|
||||
subscribeLocationSearch(listener, options) {
|
||||
return this._locationState.subscribe(listener, options);
|
||||
}
|
||||
|
||||
_handleLocationSearchState(state, change) {
|
||||
if (this._disposed || !change) return;
|
||||
if (change.type === 'started') this._activeLocationSearchGeneration = change.generation;
|
||||
else if (change.type === 'found') {
|
||||
this._searchedLocationLabel = state.destination.label || state.query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else if (change.type === 'missing') this._showToast('Location not found');
|
||||
else if (change.type === 'failed') this._showToast('Search failed');
|
||||
else if (change.type === 'settled') this._settleLocationSearchUi(change.generation);
|
||||
else if (change.type === 'reset' && this._activeLocationSearchGeneration !== null) {
|
||||
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the location bar: renders city pills from CITY_POIS, sets up
|
||||
* QWERTY keyboard navigation for POI selection, wires the search toggle
|
||||
@@ -4497,6 +4537,7 @@ export class StyleManager {
|
||||
_initLocationBar() {
|
||||
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||
this._locationControls?.destroy();
|
||||
this._locationLookupUnsubscribe?.();
|
||||
this._locationLookup?.destroy();
|
||||
this._locationLookup = new LocationSearch({
|
||||
input: this._locationSearch,
|
||||
@@ -4509,22 +4550,10 @@ export class StyleManager {
|
||||
placeSearch: this.placeSearch,
|
||||
...options,
|
||||
}),
|
||||
onStart: (generation) => {
|
||||
this._activeLocationSearchGeneration = generation;
|
||||
},
|
||||
onResult: (destination, query) => {
|
||||
this._searchedLocationLabel = destination.label || query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
},
|
||||
onMissing: () => this._showToast('Location not found'),
|
||||
onError: (error) => {
|
||||
console.error('[Search] Geocoding failed:', error);
|
||||
this._showToast('Search failed');
|
||||
},
|
||||
onSettled: (generation) => this._settleLocationSearchUi(generation),
|
||||
onError: (error) => console.error('[Search] Geocoding failed:', error),
|
||||
});
|
||||
this._locationLookupUnsubscribe = this._locationLookup.subscribe(({ initial, change }) => {
|
||||
this._locationState.publish(initial ? { type: 'reset' } : change);
|
||||
});
|
||||
this._locationControls = new LocationControls({
|
||||
elements: {
|
||||
@@ -5234,6 +5263,10 @@ export class StyleManager {
|
||||
this._feedback._globalStatusNotice = null;
|
||||
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
||||
this._disposed = true;
|
||||
this._shareState.destroy();
|
||||
this._locationState.destroy();
|
||||
this._locationLookupUnsubscribe?.();
|
||||
this._locationLookupUnsubscribe = null;
|
||||
this._lifetime.destroy();
|
||||
this._recording.destroy();
|
||||
this._panelPosition.destroy();
|
||||
|
||||
+42
-34
@@ -1,33 +1,17 @@
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
|
||||
/** One cancellable place lookup at a time, under the caller's camera authority. */
|
||||
export class LocationSearch {
|
||||
constructor({
|
||||
input,
|
||||
begin,
|
||||
isCurrent,
|
||||
beforeFly,
|
||||
search,
|
||||
onStart,
|
||||
onResult,
|
||||
onMissing,
|
||||
onError,
|
||||
onSettled,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
input,
|
||||
begin,
|
||||
isCurrent,
|
||||
beforeFly,
|
||||
search,
|
||||
onStart,
|
||||
onResult,
|
||||
onMissing,
|
||||
onError,
|
||||
onSettled,
|
||||
});
|
||||
constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) {
|
||||
Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled });
|
||||
this.controller = null;
|
||||
this.generation = 0;
|
||||
this.destroyed = false;
|
||||
this.state = { status: 'idle', searching: false, query: '', generation: null, requestId: 0, destination: null, error: null };
|
||||
this.channel = createStateChannel(() => this.state);
|
||||
}
|
||||
getState() { return this.channel.getSnapshot(); }
|
||||
subscribe(listener, options) { return this.channel.subscribe(listener, options); }
|
||||
async run(query) {
|
||||
query = String(query || '').trim();
|
||||
if (!query || this.destroyed) return;
|
||||
@@ -41,27 +25,48 @@ export class LocationSearch {
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const generation = ++this.generation;
|
||||
const current = () =>
|
||||
!this.destroyed &&
|
||||
generation === this.generation &&
|
||||
this.isCurrent(authority);
|
||||
this.onStart(authority);
|
||||
this.input.classList.add('searching');
|
||||
const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority);
|
||||
const change = (type) => ({ type, generation: authority, requestId: generation, query });
|
||||
this.state = { status: 'searching', searching: true, query, generation: authority, requestId: generation, destination: null, error: null };
|
||||
try {
|
||||
this.onStart?.(authority);
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
this.input.classList.add('searching');
|
||||
this.channel.publish(change('started'));
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
const destination = await this.search(query, {
|
||||
signal: controller.signal,
|
||||
beforeFly: () => current() && this.beforeFly(authority),
|
||||
});
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
if (destination?.cancelled) return;
|
||||
if (destination) this.onResult(destination, query);
|
||||
else this.onMissing();
|
||||
if (destination) {
|
||||
this.onResult?.(destination, query);
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
this.state = { ...this.state, status: 'found', destination: { ...destination } };
|
||||
this.channel.publish(change('found'));
|
||||
} else {
|
||||
this.onMissing?.();
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
this.state = { ...this.state, status: 'missing' };
|
||||
this.channel.publish(change('missing'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || !current()) return;
|
||||
this.onError(error);
|
||||
this.onError?.(error);
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
this.state = { ...this.state, status: 'failed', error: { message: String(error?.message || error) } };
|
||||
this.channel.publish(change('failed'));
|
||||
} finally {
|
||||
if (this.controller === controller) this.controller = null;
|
||||
if (!this.destroyed) this.onSettled(authority);
|
||||
if (!this.destroyed) {
|
||||
if (generation === this.generation) this.state = {
|
||||
...this.state, searching: false,
|
||||
status: this.state.status === 'searching' ? 'cancelled' : this.state.status,
|
||||
};
|
||||
this.onSettled?.(authority);
|
||||
this.channel.publish(change('settled'));
|
||||
}
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
@@ -70,5 +75,8 @@ export class LocationSearch {
|
||||
this.generation++;
|
||||
this.controller?.abort();
|
||||
this.controller = null;
|
||||
this.state = { ...this.state, status: 'disposed', searching: false };
|
||||
this.channel.getSnapshot();
|
||||
this.channel.destroy();
|
||||
}
|
||||
}
|
||||
@@ -108,3 +108,64 @@ test('empty queries and refused navigation do not issue a lookup', async () => {
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.ok(f.calls.some((x) => x[0] === 'blur'));
|
||||
});
|
||||
|
||||
|
||||
test('search snapshots retain the current busy owner when an older request settles', async () => {
|
||||
const f = fixture();
|
||||
const events = [];
|
||||
f.search.subscribe((event) => events.push(event));
|
||||
assert.equal(events[0].state.status, 'idle');
|
||||
const first = f.search.run('First');
|
||||
const second = f.search.run('Second');
|
||||
f.requests[0].resolve({ label: 'First' });
|
||||
await first;
|
||||
const settled = events.at(-1);
|
||||
assert.equal(settled.change.generation, 1);
|
||||
assert.equal(settled.state.generation, 2);
|
||||
assert.equal(settled.state.searching, true);
|
||||
f.requests[1].resolve({ label: 'Second', lat: 1, lng: 2 });
|
||||
await second;
|
||||
const found = events.filter((event) => event.change?.type === 'found');
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0].state.query, 'Second');
|
||||
assert.throws(() => { found[0].state.destination.label = 'Changed'; }, TypeError);
|
||||
assert.equal(f.search.getState().destination.label, 'Second');
|
||||
assert.equal(f.search.getState().searching, false);
|
||||
const count = events.length;
|
||||
f.search.destroy();
|
||||
assert.equal(f.search.getState().status, 'disposed');
|
||||
f.search.subscribe(() => events.push('late'));
|
||||
assert.equal(events.length, count);
|
||||
});
|
||||
|
||||
test('a subscriber that destroys a started lookup prevents the provider from being called', async () => {
|
||||
const f = fixture();
|
||||
f.search.subscribe(({ change }) => {
|
||||
if (change?.type === 'started') f.search.destroy();
|
||||
});
|
||||
await f.search.run('Place');
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.equal(f.search.controller, null);
|
||||
});
|
||||
|
||||
test('a reentrant replacement in a result callback cannot publish the obsolete result over the new query', async () => {
|
||||
const f = fixture();
|
||||
let replacement;
|
||||
const found = [];
|
||||
f.search.subscribe(({ state, change }) => {
|
||||
if (change?.type === 'found') found.push(state.query);
|
||||
});
|
||||
f.search.onResult = () => {
|
||||
f.search.onResult = null;
|
||||
replacement = f.search.run('Second');
|
||||
};
|
||||
const first = f.search.run('First');
|
||||
f.requests[0].resolve({ label: 'First' });
|
||||
await first;
|
||||
assert.equal(f.search.getState().query, 'Second');
|
||||
assert.equal(f.search.getState().searching, true);
|
||||
assert.deepEqual(found, []);
|
||||
f.requests[1].resolve({ label: 'Second' });
|
||||
await replacement;
|
||||
assert.deepEqual(found, ['Second']);
|
||||
});
|
||||
+28
-6
@@ -10,7 +10,7 @@ import {
|
||||
} from './scenePresentation.js';
|
||||
|
||||
export class SceneControls {
|
||||
constructor({ read, actions, elements = sceneElements() }) {
|
||||
constructor({ read, actions, subscribe, elements = sceneElements() }) {
|
||||
this.read = read;
|
||||
this.actions = actions;
|
||||
this.elements = elements;
|
||||
@@ -20,8 +20,8 @@ export class SceneControls {
|
||||
this.rowRemovers = [];
|
||||
this.playbackKeyRemover = null;
|
||||
if (!elements.select) return;
|
||||
this.renderSceneSelect();
|
||||
this.renderShotList();
|
||||
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),
|
||||
);
|
||||
@@ -47,9 +47,29 @@ export class SceneControls {
|
||||
await this.run('import', file);
|
||||
if (!this.destroyed) elements.file.value = '';
|
||||
});
|
||||
this.updateStatus('Ready');
|
||||
this.setProgress(0);
|
||||
this.setButtons(false);
|
||||
if (!subscribe) {
|
||||
this.updateStatus('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 || type === 'buttons-changed') 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('Project exported');
|
||||
if (type === 'shot-loaded') this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`);
|
||||
if (type === 'run-event' && change.event === 'shot_start') this.setButtons(state.running);
|
||||
}
|
||||
|
||||
listen(target, type, callback, removers = this.removers) {
|
||||
@@ -170,6 +190,8 @@ export class SceneControls {
|
||||
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),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { SceneControls } from './sceneControls.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
|
||||
@@ -349,3 +350,27 @@ test('the real director preserves a selected shot label for the following double
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.owner.destroy();
|
||||
Object.assign(f.state, { status: 'Ready to resume', progress: 0.25, runtime: '', playbackActive: false, keyboardEnabled: false });
|
||||
const channel = createStateChannel(() => f.state);
|
||||
const owner = new SceneControls({ read: () => f.state, actions: f.actions, elements: f.elements, subscribe: (listener) => channel.subscribe(listener) });
|
||||
assert.equal(f.elements.status.textContent, 'Ready to resume');
|
||||
const row = f.elements.shots.children[0];
|
||||
f.state.progress = 0.5;
|
||||
channel.publish({ type: 'progress-changed' });
|
||||
assert.equal(f.elements.shots.children[0], row);
|
||||
channel.publish({ type: 'project-exported' });
|
||||
assert.equal(f.elements.status.textContent, 'Project exported');
|
||||
owner.destroy();
|
||||
f.state.status = 'Late';
|
||||
channel.publish({ type: 'status-changed' });
|
||||
assert.equal(f.elements.status.textContent, 'Project exported');
|
||||
assert.equal(owner.unsubscribe, null);
|
||||
channel.destroy();
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
Reference in new issue
Block a user