mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
326 lines
9.9 KiB
JavaScript
326 lines
9.9 KiB
JavaScript
import { nextTick } from 'vue';
|
|
import { onLocaleChange, t } from '../i18n/index.js';
|
|
import { useVoiceStore } from '../ui/stores/voice.js';
|
|
import { applicationPinia } from '../ui/vueRuntime.js';
|
|
|
|
// The assistant's name on its card. i18n-ignore: product name
|
|
export const ASSISTANT_NAME = 'GEV';
|
|
|
|
/** Bars in the dock waveform. */
|
|
export const VOICE_VISUALIZER_BARS = 15;
|
|
|
|
/**
|
|
* Short state label for the narrow voice heading. The `voice` context lets
|
|
* languages use shorter words; unknown states show upper-cased.
|
|
* @param {string} status machine state (dataset.status)
|
|
* @returns {string}
|
|
*/
|
|
export function voiceStatusLabel(status) {
|
|
switch (status) {
|
|
case 'idle':
|
|
return t('OFF', { context: 'voice' });
|
|
case 'connecting':
|
|
return t('CONNECTING', { context: 'voice' });
|
|
case 'listening':
|
|
return t('LISTENING', { context: 'voice' });
|
|
case 'executing':
|
|
return t('EXECUTING', { context: 'voice' });
|
|
case 'error':
|
|
return t('ERROR', { context: 'voice' });
|
|
default:
|
|
return String(status || '').toUpperCase();
|
|
}
|
|
}
|
|
|
|
/** The card before its first render: hidden, with its default labels. */
|
|
function initialCard() {
|
|
return {
|
|
visible: false,
|
|
busy: null,
|
|
phase: '',
|
|
user: { visible: false, text: '', label: t('YOU') },
|
|
pointer: { visible: false, kind: t('THIS'), text: '' },
|
|
assistant: { visible: false, text: '' },
|
|
steps: [],
|
|
result: {
|
|
visible: false,
|
|
title: '',
|
|
chips: [],
|
|
lines: [],
|
|
referents: [],
|
|
notes: [],
|
|
},
|
|
announce: '',
|
|
};
|
|
}
|
|
|
|
/** The control as it first renders, before any owner writes to it. */
|
|
function initialState(panel) {
|
|
return {
|
|
panel,
|
|
root: {
|
|
status: 'idle',
|
|
speaker: 'idle',
|
|
provider: null,
|
|
providerChoice: null,
|
|
pushToTalk: null,
|
|
microphone: null,
|
|
rowGrace: false,
|
|
errorDismissed: false,
|
|
},
|
|
// The provider field and its select.
|
|
provider: {
|
|
hidden: true,
|
|
options: [],
|
|
value: '',
|
|
title: () => t('Changing provider reconnects voice if it is on.'),
|
|
},
|
|
tier: {
|
|
hidden: false,
|
|
text: () => t('STD'),
|
|
pressed: false,
|
|
title: () => t('Voice model tier — applies next session'),
|
|
},
|
|
cost: {
|
|
hidden: false,
|
|
text: '~$0.00',
|
|
level: 'ok',
|
|
title: () => t('Estimated session cost'),
|
|
},
|
|
costWarning: { hidden: true, text: '', level: null, title: null },
|
|
button: {
|
|
label: () =>
|
|
t('Voice control — activate to toggle voice; hold Space to speak'),
|
|
pressed: null,
|
|
},
|
|
mic: { text: () => t('ON/OFF') },
|
|
status: { text: () => voiceStatusLabel('idle') },
|
|
detail: { text: () => t('VOICE STANDBY'), title: null },
|
|
help: {
|
|
text: () =>
|
|
t('Hold Space to speak · tap Space to activate focused controls'),
|
|
},
|
|
error: { text: '' },
|
|
voicePanel: {
|
|
hidden: true,
|
|
view: null,
|
|
previewHidden: false,
|
|
settingsHidden: true,
|
|
settingsExpanded: false,
|
|
selected: 'preview',
|
|
},
|
|
previewEmpty: {
|
|
hidden: false,
|
|
text: () => t('Voice replies appear here.'),
|
|
},
|
|
preferences: {
|
|
previewChecked: true,
|
|
previewNote: '',
|
|
timeoutChoice: '5',
|
|
timeoutMinutes: '5',
|
|
timeoutInputHidden: false,
|
|
timeoutNote: () => t('These preferences stay in this browser.'),
|
|
timeoutStatus: '',
|
|
// Bumped by every owner write of the fields, so the view restores a
|
|
// refused edit but leaves a draft alone across other renders.
|
|
revision: 0,
|
|
},
|
|
card: initialCard(),
|
|
};
|
|
}
|
|
|
|
// The rendered elements the presenter focuses or measures, by name.
|
|
const ELEMENT_IDS = Object.freeze({
|
|
root: 'gev-voice-control',
|
|
button: 'gev-voice-button',
|
|
provider: 'gev-voice-provider',
|
|
voicePanel: 'gev-voice-panel',
|
|
previewPanel: 'gev-voice-preview-panel',
|
|
settingsPanel: 'gev-voice-settings-panel',
|
|
previewTab: 'gev-voice-preview-tab',
|
|
settingsTab: 'gev-voice-settings-tab',
|
|
settingsButton: 'gev-voice-settings-button',
|
|
card: 'gev-voice-card',
|
|
});
|
|
|
|
let controlCount = 0;
|
|
|
|
const sameBars = (a, b) =>
|
|
a === b ||
|
|
(Array.isArray(a) &&
|
|
Array.isArray(b) &&
|
|
a.length === b.length &&
|
|
a.every(
|
|
(bar, index) =>
|
|
bar.level === b[index].level && bar.opacity === b[index].opacity,
|
|
));
|
|
|
|
/**
|
|
* The voice control, independent of its connection backend. Session owners
|
|
* write its state by part (`update('detail', {text})`) and listen for what
|
|
* the person does (`on('button', fn)`); the command dock's VoiceControl view
|
|
* renders it from the voice store. A newer control replaces this one there.
|
|
* @param {object} [options]
|
|
* @param {boolean} [options.panel] Show the Voice Settings panel and preview.
|
|
*/
|
|
export function createVoiceControl({
|
|
panel = false,
|
|
store = useVoiceStore(applicationPinia()),
|
|
} = {}) {
|
|
const id = ++controlCount;
|
|
const listeners = new Map();
|
|
// The fields given as functions, by part: they are called again when the
|
|
// locale changes, so the control's words follow the language.
|
|
const wording = new Map();
|
|
const word = (part, patch) => {
|
|
let fields = wording.get(part);
|
|
let worded = patch;
|
|
for (const [field, value] of Object.entries(patch)) {
|
|
if (typeof value !== 'function') {
|
|
fields?.delete(field);
|
|
continue;
|
|
}
|
|
if (!fields) wording.set(part, (fields = new Map()));
|
|
fields.set(field, value);
|
|
if (worded === patch) worded = { ...patch };
|
|
worded[field] = value();
|
|
}
|
|
return worded;
|
|
};
|
|
let state = initialState(Boolean(panel));
|
|
for (const [part, value] of Object.entries(state))
|
|
if (part !== 'card' && value && typeof value === 'object')
|
|
state[part] = word(part, value);
|
|
// The default card's labels follow the locale until a card replaces it.
|
|
let defaultCard = true;
|
|
// Each bar's `--audio-level` and `--audio-opacity`, or null at rest.
|
|
let bars = null;
|
|
let removed = false;
|
|
const owns = () => !removed && store.controls === ui;
|
|
const publish = () => {
|
|
if (owns()) store.publish({ ...state, id });
|
|
};
|
|
const element = (name) =>
|
|
owns()
|
|
? (globalThis.document?.getElementById(ELEMENT_IDS[name]) ?? null)
|
|
: null;
|
|
const shown = (node) =>
|
|
Boolean(
|
|
node?.checkVisibility?.({
|
|
opacityProperty: true,
|
|
visibilityProperty: true,
|
|
}) ?? node,
|
|
);
|
|
const ui = {
|
|
get panel() {
|
|
return state.panel;
|
|
},
|
|
/** The current state; read-only, replaced on every update. */
|
|
get state() {
|
|
return state;
|
|
},
|
|
/**
|
|
* Merge `patch` into one part of the control. A field given as a
|
|
* function is shown as what it returns, and called again when the
|
|
* locale changes; give translated words that way.
|
|
*/
|
|
update(part, patch) {
|
|
if (removed) return;
|
|
state = { ...state, [part]: { ...state[part], ...word(part, patch) } };
|
|
publish();
|
|
},
|
|
/** Replace one part of the control. */
|
|
replace(part, value) {
|
|
if (removed) return;
|
|
wording.delete(part);
|
|
if (part === 'card') defaultCard = false;
|
|
state = { ...state, [part]: value };
|
|
publish();
|
|
},
|
|
/**
|
|
* Listen for an interaction the view reports: `button`, `tier`,
|
|
* `provider-change`, `settings-button`, `settings-close`, `tab`,
|
|
* `tab-keydown`, `panel-keydown`, `pointerenter`, `pointerleave`,
|
|
* `preview-change`, `timeout-select`, `timeout-input`, `card-dismiss`;
|
|
* and `locale` after a locale change, for an owner's own words.
|
|
* @returns {() => void} Removes the listener.
|
|
*/
|
|
on(name, listener) {
|
|
if (!listeners.has(name)) listeners.set(name, new Set());
|
|
listeners.get(name).add(listener);
|
|
return () => listeners.get(name)?.delete(listener);
|
|
},
|
|
/** The view reports an interaction; listeners run in the order added. */
|
|
emit(name, ...args) {
|
|
if (removed) return;
|
|
for (const listener of [...(listeners.get(name) ?? [])])
|
|
listener(...args);
|
|
},
|
|
/** The waveform's bars, or null at rest. */
|
|
get bars() {
|
|
return bars;
|
|
},
|
|
/**
|
|
* Show the waveform's bars (null rests it). Published apart from the
|
|
* rest of the control, and only when they change.
|
|
* @param {?Array<{level: string, opacity: string}>} next
|
|
*/
|
|
setBars(next) {
|
|
if (removed || sameBars(bars, next)) return;
|
|
bars = next;
|
|
if (owns()) store.publishBars(bars);
|
|
},
|
|
/** The error tray's DISMISS. */
|
|
dismissError() {
|
|
ui.update('root', { errorDismissed: true });
|
|
},
|
|
/** The rendered element by name, or null before it renders. */
|
|
element,
|
|
/** Whether the named element is rendered and visible on the page. */
|
|
visible: (name) => shown(element(name)),
|
|
/**
|
|
* Once the view has rendered the latest state, focus the first of the
|
|
* named elements that is visible, or with `anyVisibility`, that is
|
|
* rendered (focus may be what reveals it).
|
|
* @param {string[]} names
|
|
* @param {{anyVisibility?: boolean}} [options]
|
|
*/
|
|
focus(names, { anyVisibility = false } = {}) {
|
|
return nextTick(() => {
|
|
const target = names
|
|
.map(element)
|
|
.find((node) => node && (anyVisibility || shown(node)));
|
|
target?.focus?.();
|
|
return target ?? null;
|
|
});
|
|
},
|
|
/** Remove the control from the dock, unless a newer one replaced it. */
|
|
remove() {
|
|
if (removed) return;
|
|
if (store.controls === ui) {
|
|
store.connect(null);
|
|
store.publish(null);
|
|
store.publishBars(null);
|
|
}
|
|
removed = true;
|
|
offLocale();
|
|
listeners.clear();
|
|
},
|
|
};
|
|
const offLocale = onLocaleChange(() => {
|
|
if (removed) return;
|
|
for (const [part, fields] of wording) {
|
|
const patch = {};
|
|
for (const [field, value] of fields) patch[field] = value();
|
|
state = { ...state, [part]: { ...state[part], ...patch } };
|
|
}
|
|
if (defaultCard) state = { ...state, card: initialCard() };
|
|
publish();
|
|
ui.emit('locale');
|
|
});
|
|
store.connect(ui);
|
|
store.publishBars(null);
|
|
publish();
|
|
return ui;
|
|
}
|