mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Switch language in place, without a reload
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.
This commit is contained in:
1 parent
ba20bc13d8
commit
b20c80e590
87 files changed
+1194
-777
No files matched your search
+62
-15
@@ -1,5 +1,5 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
import { useVoiceStore } from '../ui/stores/voice.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
@@ -73,30 +73,32 @@ function initialState(panel) {
|
||||
hidden: true,
|
||||
options: [],
|
||||
value: '',
|
||||
title: t('Changing provider reconnects voice if it is on.'),
|
||||
title: () => t('Changing provider reconnects voice if it is on.'),
|
||||
},
|
||||
tier: {
|
||||
hidden: false,
|
||||
text: t('STD'),
|
||||
text: () => t('STD'),
|
||||
pressed: false,
|
||||
title: t('Voice model tier — applies next session'),
|
||||
title: () => t('Voice model tier — applies next session'),
|
||||
},
|
||||
cost: {
|
||||
hidden: false,
|
||||
text: '~$0.00',
|
||||
level: 'ok',
|
||||
title: t('Estimated session cost'),
|
||||
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'),
|
||||
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 },
|
||||
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'),
|
||||
text: () =>
|
||||
t('Hold Space to speak · tap Space to activate focused controls'),
|
||||
},
|
||||
error: { text: '' },
|
||||
voicePanel: {
|
||||
@@ -107,14 +109,17 @@ function initialState(panel) {
|
||||
settingsExpanded: false,
|
||||
selected: 'preview',
|
||||
},
|
||||
previewEmpty: { hidden: false, text: t('Voice replies appear here.') },
|
||||
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.'),
|
||||
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.
|
||||
@@ -164,7 +169,30 @@ export function createVoiceControl({
|
||||
} = {}) {
|
||||
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;
|
||||
@@ -191,15 +219,21 @@ export function createVoiceControl({
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
/** Merge `patch` into one part of the control. */
|
||||
/**
|
||||
* 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], ...patch } };
|
||||
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();
|
||||
},
|
||||
@@ -207,7 +241,8 @@ export function createVoiceControl({
|
||||
* 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`.
|
||||
* `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) {
|
||||
@@ -268,9 +303,21 @@ export function createVoiceControl({
|
||||
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();
|
||||
|
||||
+14
-5
@@ -68,7 +68,7 @@ export function createVoicePanelHost({
|
||||
};
|
||||
const renderActivity = () => {
|
||||
ui.update('preferences', {
|
||||
timeoutStatus:
|
||||
timeoutStatus: () =>
|
||||
nativeProvider === 'openai' || nativeProvider === 'gemini'
|
||||
? ''
|
||||
: t('Automatic stop is unavailable for this voice connection.'),
|
||||
@@ -234,9 +234,14 @@ export function createVoicePanelHost({
|
||||
card,
|
||||
getPreviewEnabled: () => preferences.snapshot().previewEnabled,
|
||||
getCockpitActive: () => readCockpitState()?.active === true,
|
||||
// Getters, so the panel words them in the current locale.
|
||||
copy: {
|
||||
previewEmpty: t('Voice replies appear here.'),
|
||||
previewDisabled: t('Preview is off.'),
|
||||
get previewEmpty() {
|
||||
return t('Voice replies appear here.');
|
||||
},
|
||||
get previewDisabled() {
|
||||
return t('Preview is off.');
|
||||
},
|
||||
},
|
||||
onPresentationIntent: remember,
|
||||
});
|
||||
@@ -245,8 +250,12 @@ export function createVoicePanelHost({
|
||||
preferences,
|
||||
copy: {
|
||||
previewNote: '',
|
||||
timeoutNote: t('These preferences stay in this browser.'),
|
||||
notSaved: t('Your choice applies now, but could not be saved.'),
|
||||
get timeoutNote() {
|
||||
return t('These preferences stay in this browser.');
|
||||
},
|
||||
get notSaved() {
|
||||
return t('Your choice applies now, but could not be saved.');
|
||||
},
|
||||
},
|
||||
onChange: (state) => {
|
||||
if (!owns()) return;
|
||||
|
||||
@@ -59,9 +59,10 @@ export function createManualVoicePanel({
|
||||
selected: view,
|
||||
});
|
||||
card?.setPresented?.(open && view === 'preview' && !suppressed());
|
||||
const previewOn = enabled();
|
||||
ui.update('previewEmpty', {
|
||||
hidden: ui.state.card.visible,
|
||||
text: enabled() ? copy.previewEmpty : copy.previewDisabled,
|
||||
text: () => (previewOn ? copy.previewEmpty : copy.previewDisabled),
|
||||
});
|
||||
if (restoreFocus) void ui.focus(['settingsButton', 'button']);
|
||||
};
|
||||
|
||||
@@ -120,8 +120,11 @@ export function bindPreferenceControls({ ui, preferences, copy, onChange }) {
|
||||
if (customSelected) custom = state.minutes;
|
||||
ui.update('preferences', {
|
||||
previewChecked: state.previewEnabled,
|
||||
previewNote: `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
timeoutNote: `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
// Functions, so a locale change words the notes again.
|
||||
previewNote: () =>
|
||||
`${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
timeoutNote: () =>
|
||||
`${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
timeoutChoice:
|
||||
state.minutes === 0 ? '0' : customSelected ? 'custom' : value,
|
||||
timeoutMinutes: String(custom),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createVoiceCommands as createCommands } from './commands.js';
|
||||
import { createGevActionRunner } from './gevActions.js';
|
||||
import { createPointerTracker } from './pointerContext.js';
|
||||
import { createReferentRegistry } from './referents.js';
|
||||
import { describeUnsavedWork, t as translate } from '../i18n/index.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
const PROVIDER_KEY = 'gev.voice.provider';
|
||||
@@ -749,15 +748,11 @@ for (const emitsIdle of [false, true]) {
|
||||
}
|
||||
|
||||
test('outer destruction disposes the runner once and removes each provider binding', async (t) => {
|
||||
const priorUnsaved = describeUnsavedWork();
|
||||
const f = compositionFixture(t);
|
||||
await f.facade.start();
|
||||
f.select('gemini');
|
||||
await settle();
|
||||
assert.equal(f.annotations.size, 1);
|
||||
assert.ok(
|
||||
describeUnsavedWork().includes(translate('the active voice session')),
|
||||
);
|
||||
const oldCardEvents = f.views[0].cardView.events.length;
|
||||
for (const callback of f.annotations) callback({ id: 'fixture-outline' });
|
||||
assert.equal(f.views[0].cardView.outlines.length, 0);
|
||||
@@ -771,7 +766,6 @@ test('outer destruction disposes the runner once and removes each provider bindi
|
||||
assert.equal(f.facade.signal.aborted, true);
|
||||
assert.equal(f.facade.disposed, true);
|
||||
assert.equal(f.annotations.size, 0);
|
||||
assert.deepEqual(describeUnsavedWork(), priorUnsaved);
|
||||
const finalEvents = f.events.length;
|
||||
const starts = f.instances.map((instance) => instance.starts.length);
|
||||
for (const [index, view] of f.views.entries()) {
|
||||
|
||||
@@ -415,23 +415,26 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
});
|
||||
this.updateVoiceButtonLabel();
|
||||
const shownStatus = STATUS_STATES.has(status) ? status : 'idle';
|
||||
this.ui.update('status', { text: voiceStatusLabel(shownStatus) });
|
||||
const resolvedDetail =
|
||||
status === 'listening' && this.pushToTalkMode
|
||||
? this.pushToTalkKeyHeld
|
||||
this.ui.update('status', { text: () => voiceStatusLabel(shownStatus) });
|
||||
// Words are given as functions, so a locale change words them again.
|
||||
const holdToTalk = status === 'listening' && this.pushToTalkMode;
|
||||
const held = this.pushToTalkKeyHeld;
|
||||
const resolvedDetail = () =>
|
||||
holdToTalk
|
||||
? held
|
||||
? t('Release Space to send')
|
||||
: t('Hold Space to talk')
|
||||
: detail;
|
||||
const primaryDetail =
|
||||
const primaryDetail = () =>
|
||||
status === 'error'
|
||||
? t('VOICE UNAVAILABLE')
|
||||
: resolvedDetail ||
|
||||
: resolvedDetail() ||
|
||||
(status === 'idle' ? t('VOICE STANDBY') : t('VOICE ACTIVE'));
|
||||
this.ui.update('detail', { text: primaryDetail, title: primaryDetail });
|
||||
this.ui.update('error', {
|
||||
text:
|
||||
text: () =>
|
||||
status === 'error'
|
||||
? resolvedDetail || t('Voice session could not be started.')
|
||||
? resolvedDetail() || t('Voice session could not be started.')
|
||||
: '',
|
||||
});
|
||||
if (status === 'idle' || status === 'connecting' || status === 'error') {
|
||||
|
||||
@@ -363,12 +363,10 @@ export class RealtimeInput {
|
||||
updateVoiceButtonLabel() {
|
||||
if (!this.ui) return;
|
||||
// The mic button's caption; the first-run tip names it the same way.
|
||||
this.ui.update('mic', { text: t('MIC') });
|
||||
this.ui.update('mic', { text: () => t('MIC') });
|
||||
const { pushToTalkMode, pushToTalkKeyHeld } = this;
|
||||
this.ui.update('help', {
|
||||
text: resolveVoiceControlHint(
|
||||
this.pushToTalkMode,
|
||||
this.pushToTalkKeyHeld,
|
||||
),
|
||||
text: () => resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,18 @@ export function createRealtimeSession({
|
||||
sendMapEvent: (event) => controller.notifyMapEvent(event),
|
||||
ignoreButtonClick: () => Boolean(controller.spaceKeyHeld),
|
||||
bindControls() {
|
||||
// Kept as the remover; a full stop releases it.
|
||||
controller.tierHandler = controller.ui.on('tier', () =>
|
||||
// Kept as the remover; a full stop releases it. The cost readout and
|
||||
// tier toggle are worded again in a new locale.
|
||||
const offTier = controller.ui.on('tier', () =>
|
||||
controller.toggleVoiceTier(),
|
||||
);
|
||||
const offLocale = controller.ui.on('locale', () =>
|
||||
controller.syncCostUi(),
|
||||
);
|
||||
controller.tierHandler = () => {
|
||||
offTier();
|
||||
offLocale();
|
||||
};
|
||||
controller.syncCostUi();
|
||||
controller.bindPushToTalkShortcut();
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createVoiceSession } from './session.js';
|
||||
import { createRealtimeSession } from './realtimeSession.js';
|
||||
import { realtimeSessionEvent } from './realtimeEvents.js';
|
||||
import { createVoiceCommands } from './sessionCommands.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
function deferred() {
|
||||
@@ -294,20 +293,15 @@ test('common button and annotation bindings work with an alternate adapter and c
|
||||
assert.equal(ui.state.help.text, 'Activate to toggle voice');
|
||||
assert.equal(ui.state.status.text, 'LISTENING');
|
||||
assert.equal(ui.state.detail.text, 'Ready');
|
||||
// Switching language (a reload) asks first during a session.
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
subscribed({ status: 'resolved' });
|
||||
assert.deepEqual(events, [
|
||||
{ type: 'map_annotation_outline', status: 'resolved' },
|
||||
]);
|
||||
click();
|
||||
assert.equal(controls.state, 'idle');
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
click();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
lifetime.abort();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
|
||||
assert.equal(subscribed, null);
|
||||
assert.equal(removed, 1);
|
||||
click();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createVoiceSession } from './session.js';
|
||||
import { VoiceCardControls } from './voiceCard.js';
|
||||
import { PointerReticle } from './pointerReticle.js';
|
||||
import { VOICE_PROVIDERS } from './providerCapabilities.js';
|
||||
import { msg, registerUnsavedWork, t } from '../i18n/index.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
function createDefaultReticle(pointer) {
|
||||
const element = pointer?.element;
|
||||
@@ -74,12 +74,13 @@ export function createVoiceCommands({
|
||||
})),
|
||||
}),
|
||||
value: provider,
|
||||
title: [
|
||||
t('Changing provider reconnects voice if it is on.'),
|
||||
capabilities.advisoryLimit,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
title: () =>
|
||||
[
|
||||
t('Changing provider reconnects voice if it is on.'),
|
||||
capabilities.advisoryLimit,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
});
|
||||
ui.update('root', { provider });
|
||||
}
|
||||
@@ -91,8 +92,8 @@ export function createVoiceCommands({
|
||||
ui.update('tier', { hidden: !capabilities.costControls });
|
||||
ui.update('cost', { hidden: !capabilities.costControls });
|
||||
if (!capabilities.pushToTalk) {
|
||||
ui.update('button', { label: t('Toggle voice control') });
|
||||
ui.update('help', { text: t('Activate to toggle voice') });
|
||||
ui.update('button', { label: () => t('Toggle voice control') });
|
||||
ui.update('help', { text: () => t('Activate to toggle voice') });
|
||||
}
|
||||
// Retain the existing controller's inspection surface for browser tools.
|
||||
const controls = adapter.controller || session;
|
||||
@@ -105,17 +106,17 @@ export function createVoiceCommands({
|
||||
...(event.state === 'error' && { errorDismissed: false }),
|
||||
});
|
||||
// States are machine values; only the label is translated.
|
||||
ui.update('status', { text: voiceStatusLabel(event.state) });
|
||||
ui.update('status', { text: () => voiceStatusLabel(event.state) });
|
||||
// The portable session reports 'Voice off' in English: show the idle
|
||||
// label instead. Other details are provider messages, shown as they come.
|
||||
const idle = event.state === 'idle';
|
||||
const detail = event.detail === 'Voice off' ? '' : event.detail;
|
||||
ui.update('detail', {
|
||||
text: detail || (idle ? t('Voice off') : t('Voice active')),
|
||||
text: () => detail || (idle ? t('Voice off') : t('Voice active')),
|
||||
});
|
||||
ui.update('button', { pressed: session.isActive() });
|
||||
ui.update('error', {
|
||||
text:
|
||||
text: () =>
|
||||
event.state === 'error'
|
||||
? event.detail || t('Voice could not be started.')
|
||||
: '',
|
||||
@@ -169,10 +170,6 @@ export function createVoiceCommands({
|
||||
card?.outline(event);
|
||||
session.sendMapEvent({ type: 'map_annotation_outline', ...event });
|
||||
});
|
||||
// A reload (the language switch) ends the conversation: ask first.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||
session.isActive() ? msg('the active voice session') : null,
|
||||
);
|
||||
const buttonHandler = () => {
|
||||
if (adapter.ignoreButtonClick?.()) return;
|
||||
if (session.isActive()) {
|
||||
@@ -188,7 +185,6 @@ export function createVoiceCommands({
|
||||
cleaned = true;
|
||||
offButton();
|
||||
offProvider?.();
|
||||
unregisterUnsavedWork();
|
||||
annotationUnsubscribe?.();
|
||||
updateStatus();
|
||||
updateCard?.();
|
||||
|
||||
@@ -40,6 +40,10 @@ export class VoiceCardControls {
|
||||
this.hideTimer = null;
|
||||
this.destroyed = false;
|
||||
if (!ui) return;
|
||||
// The card's labels are worded again in a new locale.
|
||||
this.offLocale = ui.on('locale', () => {
|
||||
if (!this.destroyed) this.render();
|
||||
});
|
||||
this.offDismiss = ui.on('card-dismiss', () =>
|
||||
this.handle({ type: 'dismiss' }),
|
||||
);
|
||||
@@ -142,5 +146,6 @@ export class VoiceCardControls {
|
||||
this.destroyed = true;
|
||||
this.cancelIdle();
|
||||
this.offDismiss?.();
|
||||
this.offLocale?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user