even-toolkit 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/stt/providers/whisper-local/provider.js +1 -1
- package/glasses/action-bar.ts +57 -0
- package/glasses/action-map.ts +41 -0
- package/glasses/bridge.ts +306 -0
- package/glasses/canvas-renderer.ts +86 -0
- package/glasses/composer.ts +69 -0
- package/glasses/gestures.ts +60 -0
- package/glasses/index.ts +10 -0
- package/glasses/keep-alive.ts +30 -0
- package/glasses/keyboard.ts +64 -0
- package/glasses/layout.ts +121 -0
- package/glasses/paginate-text.ts +85 -0
- package/glasses/png-utils.ts +97 -0
- package/glasses/splash.ts +298 -0
- package/glasses/text-clean.ts +38 -0
- package/glasses/text-utils.ts +50 -0
- package/glasses/timer-display.ts +91 -0
- package/glasses/types.ts +59 -0
- package/glasses/upng.d.ts +19 -0
- package/glasses/useFlashPhase.ts +30 -0
- package/glasses/useGlasses.ts +214 -0
- package/package.json +3 -1
- package/stt/audio/buffer.ts +40 -0
- package/stt/audio/pcm-utils.ts +60 -0
- package/stt/audio/resample.ts +18 -0
- package/stt/audio/vad.ts +61 -0
- package/stt/engine.ts +274 -0
- package/stt/i18n.ts +39 -0
- package/stt/index.ts +10 -0
- package/stt/providers/deepgram.ts +178 -0
- package/stt/providers/web-speech.ts +221 -0
- package/stt/providers/whisper-api.ts +146 -0
- package/stt/providers/whisper-local/provider.ts +226 -0
- package/stt/providers/whisper-local/worker.ts +40 -0
- package/stt/react/useSTT.ts +113 -0
- package/stt/registry.ts +24 -0
- package/stt/sources/glass-bridge.ts +67 -0
- package/stt/sources/microphone.ts +75 -0
- package/stt/types.ts +104 -0
|
@@ -23,7 +23,7 @@ export class WhisperLocalProvider {
|
|
|
23
23
|
return new Promise((resolve, reject) => {
|
|
24
24
|
this.initResolve = resolve;
|
|
25
25
|
this.initReject = reject;
|
|
26
|
-
this.worker = new Worker(new URL('./worker.
|
|
26
|
+
this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
|
|
27
27
|
this.worker.onmessage = (e) => {
|
|
28
28
|
this.handleWorkerMessage(e.data);
|
|
29
29
|
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared action button bar for G2 glasses display.
|
|
3
|
+
*
|
|
4
|
+
* Renders a row of named buttons with triangle indicators:
|
|
5
|
+
* ▶Timer◀ Scroll ▷Steps◁
|
|
6
|
+
*
|
|
7
|
+
* - Selected button (in button-select mode): solid triangles ▶Name◀
|
|
8
|
+
* - Active button (mode entered): blinking triangles ▶Name◀ / ▷Name◁
|
|
9
|
+
* - Inactive button: plain Name
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build an action bar string from a list of button names.
|
|
14
|
+
*
|
|
15
|
+
* @param buttons Array of button label strings, e.g. ['Timer', 'Scroll', 'Steps']
|
|
16
|
+
* @param selectedIndex Index of the currently highlighted button (in button-select mode)
|
|
17
|
+
* @param activeLabel Label of the currently active mode button (e.g. 'Scroll'), or null if in button-select mode
|
|
18
|
+
* @param flashPhase Current blink phase (true = filled triangles, false = empty)
|
|
19
|
+
*/
|
|
20
|
+
export function buildActionBar(
|
|
21
|
+
buttons: string[],
|
|
22
|
+
selectedIndex: number,
|
|
23
|
+
activeLabel: string | null,
|
|
24
|
+
flashPhase: boolean,
|
|
25
|
+
): string {
|
|
26
|
+
const activeIdx = activeLabel ? buttons.indexOf(activeLabel) : -1;
|
|
27
|
+
|
|
28
|
+
return buttons.map((name, i) => {
|
|
29
|
+
if (activeIdx === i) {
|
|
30
|
+
// Active mode: blink between filled and empty triangles
|
|
31
|
+
const L = flashPhase ? '\u25B6' : '\u25B7'; // ▶ / ▷
|
|
32
|
+
const R = flashPhase ? '\u25C0' : '\u25C1'; // ◀ / ◁
|
|
33
|
+
return `${L}${name}${R}`;
|
|
34
|
+
}
|
|
35
|
+
if (activeIdx < 0 && i === selectedIndex) {
|
|
36
|
+
// Selected in button-select mode: solid triangles
|
|
37
|
+
return `\u25B6${name}\u25C0`;
|
|
38
|
+
}
|
|
39
|
+
return ` ${name} `;
|
|
40
|
+
}).join(' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a static action bar (no blinking, always solid triangles on selected).
|
|
45
|
+
* Useful for screens like recipe detail or completion where there's no mode switching.
|
|
46
|
+
*/
|
|
47
|
+
export function buildStaticActionBar(
|
|
48
|
+
buttons: string[],
|
|
49
|
+
selectedIndex: number,
|
|
50
|
+
): string {
|
|
51
|
+
return buttons.map((name, i) => {
|
|
52
|
+
if (i === selectedIndex) {
|
|
53
|
+
return `\u25B6${name}\u25C0`;
|
|
54
|
+
}
|
|
55
|
+
return ` ${name} `;
|
|
56
|
+
}).join(' ');
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EvenHubEvent } from '@evenrealities/even_hub_sdk';
|
|
2
|
+
import { OsEventTypeList } from '@evenrealities/even_hub_sdk';
|
|
3
|
+
import type { GlassAction } from './types';
|
|
4
|
+
import { tryConsumeTap, isScrollSuppressed, isScrollDebounced } from './gestures';
|
|
5
|
+
|
|
6
|
+
export function mapGlassEvent(event: EvenHubEvent): GlassAction | null {
|
|
7
|
+
if (!event) return null;
|
|
8
|
+
try {
|
|
9
|
+
const ev = event.listEvent ?? event.textEvent ?? event.sysEvent;
|
|
10
|
+
if (!ev) return null;
|
|
11
|
+
return mapEvent(ev);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mapEvent(event: { eventType?: number; currentSelectItemIndex?: number }): GlassAction | null {
|
|
18
|
+
const et = event.eventType;
|
|
19
|
+
switch (et) {
|
|
20
|
+
case OsEventTypeList.CLICK_EVENT:
|
|
21
|
+
if (!tryConsumeTap('tap')) return null;
|
|
22
|
+
return { type: 'SELECT_HIGHLIGHTED' };
|
|
23
|
+
case OsEventTypeList.DOUBLE_CLICK_EVENT:
|
|
24
|
+
if (!tryConsumeTap('double')) return null;
|
|
25
|
+
return { type: 'GO_BACK' };
|
|
26
|
+
case OsEventTypeList.SCROLL_TOP_EVENT:
|
|
27
|
+
if (isScrollDebounced('prev') || isScrollSuppressed()) return null;
|
|
28
|
+
return { type: 'HIGHLIGHT_MOVE', direction: 'up' };
|
|
29
|
+
case OsEventTypeList.SCROLL_BOTTOM_EVENT:
|
|
30
|
+
if (isScrollDebounced('next') || isScrollSuppressed()) return null;
|
|
31
|
+
return { type: 'HIGHLIGHT_MOVE', direction: 'down' };
|
|
32
|
+
default:
|
|
33
|
+
// Simulator omits eventType for CLICK_EVENT (value 0).
|
|
34
|
+
// Catch: currentSelectItemIndex present, or eventType missing entirely.
|
|
35
|
+
if (et == null || (event.currentSelectItemIndex != null && (et as number) === 0)) {
|
|
36
|
+
if (!tryConsumeTap('tap')) return null;
|
|
37
|
+
return { type: 'SELECT_HIGHLIGHTED' };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { EvenBetterSdk } from '@jappyjan/even-better-sdk';
|
|
2
|
+
import type { EvenBetterPage, EvenBetterTextElement } from '@jappyjan/even-better-sdk';
|
|
3
|
+
import {
|
|
4
|
+
RebuildPageContainer,
|
|
5
|
+
ImageContainerProperty,
|
|
6
|
+
ImageRawDataUpdate,
|
|
7
|
+
TextContainerProperty,
|
|
8
|
+
TextContainerUpgrade,
|
|
9
|
+
type EvenAppBridge,
|
|
10
|
+
type EvenHubEvent,
|
|
11
|
+
} from '@evenrealities/even_hub_sdk';
|
|
12
|
+
import { DISPLAY_W, DISPLAY_H, CHART_TEXT, IMAGE_TILES } from './layout';
|
|
13
|
+
import type { PageMode } from './types';
|
|
14
|
+
import { notifyTextUpdate } from './gestures';
|
|
15
|
+
|
|
16
|
+
function noBorder(el: EvenBetterTextElement): EvenBetterTextElement {
|
|
17
|
+
return el.setBorder(b => b.setWidth(0).setColor('0').setRadius(0));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ColumnConfig {
|
|
21
|
+
x: number;
|
|
22
|
+
w: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class EvenHubBridge {
|
|
26
|
+
private sdk: EvenBetterSdk;
|
|
27
|
+
private rawBridge: EvenAppBridge | null = null;
|
|
28
|
+
private _currentMode: PageMode | null = null;
|
|
29
|
+
private _pageReady = false;
|
|
30
|
+
|
|
31
|
+
// ── SDK-managed pages ──
|
|
32
|
+
|
|
33
|
+
// Single text page (settings, simple screens)
|
|
34
|
+
private textPage!: EvenBetterPage;
|
|
35
|
+
private textContent!: EvenBetterTextElement;
|
|
36
|
+
|
|
37
|
+
// Column page (watchlist, tables)
|
|
38
|
+
private columnPage!: EvenBetterPage;
|
|
39
|
+
private columnElements: EvenBetterTextElement[] = [];
|
|
40
|
+
|
|
41
|
+
// Chart dummy (for SDK state tracking before raw bridge chart/home)
|
|
42
|
+
private chartDummyPage!: EvenBetterPage;
|
|
43
|
+
|
|
44
|
+
constructor(columns?: ColumnConfig[]) {
|
|
45
|
+
this.sdk = new EvenBetterSdk();
|
|
46
|
+
const cols = columns ?? [
|
|
47
|
+
{ x: 0, w: 192 },
|
|
48
|
+
{ x: 192, w: 192 },
|
|
49
|
+
{ x: 384, w: DISPLAY_W - 384 },
|
|
50
|
+
];
|
|
51
|
+
this.createPages(cols);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get pageReady(): boolean { return this._pageReady; }
|
|
55
|
+
get currentMode(): PageMode | null { return this._currentMode; }
|
|
56
|
+
/** Alias matching even-market naming convention */
|
|
57
|
+
get currentLayout(): PageMode | null { return this._currentMode; }
|
|
58
|
+
|
|
59
|
+
private createPages(cols: ColumnConfig[]): void {
|
|
60
|
+
// ── Text page: empty overlay (event capture, no bounce) + visible content ──
|
|
61
|
+
this.textPage = this.sdk.createPage('text');
|
|
62
|
+
const textOverlay = noBorder(this.textPage.addTextElement(''));
|
|
63
|
+
textOverlay
|
|
64
|
+
.setPosition(p => p.setX(0).setY(0))
|
|
65
|
+
.setSize(s => s.setWidth(DISPLAY_W).setHeight(DISPLAY_H));
|
|
66
|
+
textOverlay.markAsEventCaptureElement();
|
|
67
|
+
|
|
68
|
+
this.textContent = noBorder(this.textPage.addTextElement(''));
|
|
69
|
+
this.textContent
|
|
70
|
+
.setPosition(p => p.setX(0).setY(0))
|
|
71
|
+
.setSize(s => s.setWidth(DISPLAY_W).setHeight(DISPLAY_H));
|
|
72
|
+
|
|
73
|
+
// ── Column page: empty overlay + N column text elements (max 3 columns + overlay = 4 containers) ──
|
|
74
|
+
this.columnPage = this.sdk.createPage('columns');
|
|
75
|
+
const colOverlay = noBorder(this.columnPage.addTextElement(''));
|
|
76
|
+
colOverlay
|
|
77
|
+
.setPosition(p => p.setX(0).setY(0))
|
|
78
|
+
.setSize(s => s.setWidth(DISPLAY_W).setHeight(DISPLAY_H));
|
|
79
|
+
colOverlay.markAsEventCaptureElement();
|
|
80
|
+
|
|
81
|
+
this.columnElements = cols.map((col) => {
|
|
82
|
+
const el = noBorder(this.columnPage.addTextElement(''));
|
|
83
|
+
el.setPosition(p => p.setX(col.x).setY(0))
|
|
84
|
+
.setSize(s => s.setWidth(col.w).setHeight(DISPLAY_H));
|
|
85
|
+
return el;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── Chart dummy page (for SDK state tracking) ──
|
|
89
|
+
this.chartDummyPage = this.sdk.createPage('chart-dummy');
|
|
90
|
+
const dummyText = noBorder(this.chartDummyPage.addTextElement(''));
|
|
91
|
+
dummyText
|
|
92
|
+
.setPosition(p => p.setX(0).setY(0))
|
|
93
|
+
.setSize(s => s.setWidth(1).setHeight(1));
|
|
94
|
+
dummyText.markAsEventCaptureElement();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async init(): Promise<void> {
|
|
98
|
+
this.rawBridge = await EvenBetterSdk.getRawBridge() as unknown as EvenAppBridge;
|
|
99
|
+
this._pageReady = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Setup (required before chart/home layout switch) ──
|
|
103
|
+
|
|
104
|
+
async setupTextPage(): Promise<boolean> {
|
|
105
|
+
if (!this._pageReady) return false;
|
|
106
|
+
try {
|
|
107
|
+
this.textContent.setContent('');
|
|
108
|
+
await this.sdk.renderPage(this.textPage);
|
|
109
|
+
this._currentMode = 'text';
|
|
110
|
+
return true;
|
|
111
|
+
} catch { return false; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Text page (single full-screen text, no bounce) ──
|
|
115
|
+
|
|
116
|
+
async showTextPage(content: string): Promise<void> {
|
|
117
|
+
if (!this._pageReady) return;
|
|
118
|
+
this.textContent.setContent(content);
|
|
119
|
+
notifyTextUpdate();
|
|
120
|
+
await this.sdk.renderPage(this.textPage);
|
|
121
|
+
this._currentMode = 'text';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async updateText(content: string): Promise<void> {
|
|
125
|
+
if (!this._pageReady) return;
|
|
126
|
+
this.textContent.setContent(content);
|
|
127
|
+
notifyTextUpdate();
|
|
128
|
+
await this.sdk.renderPage(this.textPage);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Column page (multi-column text, no bounce) ──
|
|
132
|
+
|
|
133
|
+
async showColumnPage(columns: string[]): Promise<void> {
|
|
134
|
+
if (!this._pageReady) return;
|
|
135
|
+
for (let i = 0; i < this.columnElements.length && i < columns.length; i++) {
|
|
136
|
+
this.columnElements[i]!.setContent(columns[i]!);
|
|
137
|
+
}
|
|
138
|
+
notifyTextUpdate();
|
|
139
|
+
await this.sdk.renderPage(this.columnPage);
|
|
140
|
+
this._currentMode = 'columns';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async updateColumns(columns: string[]): Promise<void> {
|
|
144
|
+
if (!this._pageReady) return;
|
|
145
|
+
for (let i = 0; i < this.columnElements.length && i < columns.length; i++) {
|
|
146
|
+
this.columnElements[i]!.setContent(columns[i]!);
|
|
147
|
+
}
|
|
148
|
+
notifyTextUpdate();
|
|
149
|
+
await this.sdk.renderPage(this.columnPage);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Convenience: Watchlist (3-column layout) ──
|
|
153
|
+
|
|
154
|
+
async switchToWatchlist(colSym: string, colPrice: string, colPct: string): Promise<boolean> {
|
|
155
|
+
if (!this._pageReady) return false;
|
|
156
|
+
try {
|
|
157
|
+
await this.showColumnPage([colSym, colPrice, colPct]);
|
|
158
|
+
return true;
|
|
159
|
+
} catch { return false; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async updateWatchlist(colSym: string, colPrice: string, colPct: string): Promise<void> {
|
|
163
|
+
await this.updateColumns([colSym, colPrice, colPct]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Convenience: Settings (full-screen text) ──
|
|
167
|
+
|
|
168
|
+
async switchToSettings(text: string): Promise<boolean> {
|
|
169
|
+
if (!this._pageReady) return false;
|
|
170
|
+
try {
|
|
171
|
+
await this.showTextPage(text);
|
|
172
|
+
return true;
|
|
173
|
+
} catch { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async updateSettings(text: string): Promise<void> {
|
|
177
|
+
await this.updateText(text);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Home page (N images + empty overlay + menu text containers, no bounce) ──
|
|
181
|
+
|
|
182
|
+
async switchToHomeLayout(menuText: string, imageTiles?: { id: number; name: string; x: number; y: number; w: number; h: number }[]): Promise<boolean> {
|
|
183
|
+
if (!this.rawBridge || !this._pageReady) return false;
|
|
184
|
+
try {
|
|
185
|
+
await this.showHomePage(menuText, imageTiles);
|
|
186
|
+
return true;
|
|
187
|
+
} catch { return false; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async showHomePage(menuText: string, imageTiles?: { id: number; name: string; x: number; y: number; w: number; h: number }[]): Promise<void> {
|
|
191
|
+
if (!this.rawBridge || !this._pageReady) return;
|
|
192
|
+
await this.sdk.renderPage(this.chartDummyPage);
|
|
193
|
+
|
|
194
|
+
const tiles = imageTiles && imageTiles.length > 0 ? imageTiles : [];
|
|
195
|
+
|
|
196
|
+
// Text starts below the first image tile (persistent logo tile).
|
|
197
|
+
// Extra tiles (e.g. splash "Loading...") overlap with text area — cleared with black on transition.
|
|
198
|
+
const textY = tiles.length > 0 ? tiles[0]!.y + tiles[0]!.h : 0;
|
|
199
|
+
|
|
200
|
+
await this.rawBridge.rebuildPageContainer(
|
|
201
|
+
new RebuildPageContainer({
|
|
202
|
+
containerTotalNum: 2 + tiles.length,
|
|
203
|
+
textObject: [
|
|
204
|
+
new TextContainerProperty({
|
|
205
|
+
containerID: 1, containerName: 'overlay',
|
|
206
|
+
xPosition: 0, yPosition: 0, width: DISPLAY_W, height: DISPLAY_H,
|
|
207
|
+
borderWidth: 0, borderColor: 0, paddingLength: 0,
|
|
208
|
+
content: '', isEventCapture: 1,
|
|
209
|
+
}),
|
|
210
|
+
new TextContainerProperty({
|
|
211
|
+
containerID: 5, containerName: 'menu',
|
|
212
|
+
xPosition: 0, yPosition: textY, width: DISPLAY_W, height: DISPLAY_H - textY,
|
|
213
|
+
borderWidth: 0, borderColor: 0, paddingLength: 6,
|
|
214
|
+
content: menuText, isEventCapture: 0,
|
|
215
|
+
}),
|
|
216
|
+
],
|
|
217
|
+
imageObject: tiles.map(t =>
|
|
218
|
+
new ImageContainerProperty({
|
|
219
|
+
containerID: t.id, containerName: t.name,
|
|
220
|
+
xPosition: t.x, yPosition: t.y, width: t.w, height: t.h,
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
this._currentMode = 'home';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async updateHomeText(content: string): Promise<void> {
|
|
229
|
+
if (!this.rawBridge || !this._pageReady || this._currentMode !== 'home') return;
|
|
230
|
+
notifyTextUpdate();
|
|
231
|
+
await this.rawBridge.textContainerUpgrade(
|
|
232
|
+
new TextContainerUpgrade({
|
|
233
|
+
containerID: 5, containerName: 'menu',
|
|
234
|
+
contentOffset: 0, contentLength: 2000, content,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Chart page (3 image tiles + 1 text = 4 containers) ──
|
|
240
|
+
|
|
241
|
+
async switchToChartLayout(topText: string): Promise<boolean> {
|
|
242
|
+
if (!this.rawBridge || !this._pageReady) return false;
|
|
243
|
+
if (this._currentMode === 'chart') return true;
|
|
244
|
+
try {
|
|
245
|
+
await this.showChartPage(topText);
|
|
246
|
+
return true;
|
|
247
|
+
} catch { return false; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async showChartPage(topText: string): Promise<void> {
|
|
251
|
+
if (!this.rawBridge || !this._pageReady) return;
|
|
252
|
+
if (this._currentMode === 'chart') return;
|
|
253
|
+
await this.sdk.renderPage(this.chartDummyPage);
|
|
254
|
+
await this.rawBridge.rebuildPageContainer(
|
|
255
|
+
new RebuildPageContainer({
|
|
256
|
+
containerTotalNum: 4,
|
|
257
|
+
textObject: [
|
|
258
|
+
new TextContainerProperty({
|
|
259
|
+
containerID: CHART_TEXT.id, containerName: CHART_TEXT.name,
|
|
260
|
+
xPosition: CHART_TEXT.x, yPosition: CHART_TEXT.y,
|
|
261
|
+
width: CHART_TEXT.w, height: CHART_TEXT.h,
|
|
262
|
+
borderWidth: 0, borderColor: 0, paddingLength: 0,
|
|
263
|
+
content: topText, isEventCapture: 1,
|
|
264
|
+
}),
|
|
265
|
+
],
|
|
266
|
+
imageObject: IMAGE_TILES.map((t) =>
|
|
267
|
+
new ImageContainerProperty({
|
|
268
|
+
containerID: t.id, containerName: t.name,
|
|
269
|
+
xPosition: t.x, yPosition: t.y, width: t.w, height: t.h,
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
this._currentMode = 'chart';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async updateChartText(content: string): Promise<void> {
|
|
278
|
+
if (!this.rawBridge || !this._pageReady || this._currentMode !== 'chart') return;
|
|
279
|
+
notifyTextUpdate();
|
|
280
|
+
await this.rawBridge.textContainerUpgrade(
|
|
281
|
+
new TextContainerUpgrade({
|
|
282
|
+
containerID: CHART_TEXT.id, containerName: CHART_TEXT.name,
|
|
283
|
+
contentOffset: 0, contentLength: 2000, content,
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Image sending (for home + chart modes) ──
|
|
289
|
+
|
|
290
|
+
async sendImage(containerID: number, containerName: string, pngBytes: Uint8Array): Promise<void> {
|
|
291
|
+
if (!this.rawBridge || !this._pageReady || this._currentMode === 'text' || this._currentMode === 'columns' || pngBytes.length === 0) return;
|
|
292
|
+
await this.rawBridge.updateImageRawData(
|
|
293
|
+
new ImageRawDataUpdate({ containerID, containerName, imageData: pngBytes }),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Events ──
|
|
298
|
+
|
|
299
|
+
onEvent(handler: (event: EvenHubEvent) => void): void {
|
|
300
|
+
this.sdk.addEventListener(handler);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
dispose(): void {
|
|
304
|
+
this.rawBridge = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { DISPLAY_W, DISPLAY_H } from './layout';
|
|
2
|
+
import { canvasToPngBytes } from './png-utils';
|
|
3
|
+
import type { DisplayData, DisplayLine } from './types';
|
|
4
|
+
|
|
5
|
+
const FONT_SIZE = 22;
|
|
6
|
+
const LINE_HEIGHT = 28;
|
|
7
|
+
const PADDING_LEFT = 12;
|
|
8
|
+
const PADDING_TOP = 8;
|
|
9
|
+
const FONT = `${FONT_SIZE}px "Courier New", monospace`;
|
|
10
|
+
|
|
11
|
+
let canvas: HTMLCanvasElement | null = null;
|
|
12
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
13
|
+
|
|
14
|
+
function ensureCanvas(): CanvasRenderingContext2D {
|
|
15
|
+
if (!canvas) {
|
|
16
|
+
canvas = document.createElement('canvas');
|
|
17
|
+
canvas.width = DISPLAY_W;
|
|
18
|
+
canvas.height = DISPLAY_H;
|
|
19
|
+
const container = document.getElementById('glasses-canvas');
|
|
20
|
+
if (container) container.appendChild(canvas);
|
|
21
|
+
}
|
|
22
|
+
if (!ctx) {
|
|
23
|
+
ctx = canvas.getContext('2d')!;
|
|
24
|
+
}
|
|
25
|
+
return ctx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getCanvas(): HTMLCanvasElement {
|
|
29
|
+
ensureCanvas();
|
|
30
|
+
return canvas!;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function drawLine(ctx: CanvasRenderingContext2D, line: DisplayLine, y: number): void {
|
|
34
|
+
const x = PADDING_LEFT;
|
|
35
|
+
|
|
36
|
+
if (line.style === 'separator') {
|
|
37
|
+
ctx.strokeStyle = '#333333';
|
|
38
|
+
ctx.lineWidth = 1;
|
|
39
|
+
ctx.beginPath();
|
|
40
|
+
ctx.moveTo(x, y + LINE_HEIGHT / 2);
|
|
41
|
+
ctx.lineTo(DISPLAY_W - PADDING_LEFT, y + LINE_HEIGHT / 2);
|
|
42
|
+
ctx.stroke();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (line.inverted) {
|
|
47
|
+
ctx.fillStyle = '#606060';
|
|
48
|
+
ctx.fillRect(0, y, DISPLAY_W, LINE_HEIGHT);
|
|
49
|
+
ctx.fillStyle = '#000000';
|
|
50
|
+
ctx.font = FONT;
|
|
51
|
+
ctx.fillText(line.text, x, y + FONT_SIZE);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ctx.font = FONT;
|
|
56
|
+
|
|
57
|
+
switch (line.style) {
|
|
58
|
+
case 'meta':
|
|
59
|
+
ctx.fillStyle = '#808080';
|
|
60
|
+
break;
|
|
61
|
+
case 'normal':
|
|
62
|
+
default:
|
|
63
|
+
ctx.fillStyle = '#e0e0e0';
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx.fillText(line.text, x, y + FONT_SIZE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function drawToCanvas(data: DisplayData): void {
|
|
71
|
+
const c = ensureCanvas();
|
|
72
|
+
|
|
73
|
+
c.fillStyle = '#000000';
|
|
74
|
+
c.fillRect(0, 0, DISPLAY_W, DISPLAY_H);
|
|
75
|
+
|
|
76
|
+
let y = PADDING_TOP;
|
|
77
|
+
for (const line of data.lines) {
|
|
78
|
+
drawLine(c, line, y);
|
|
79
|
+
y += LINE_HEIGHT;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function renderToImage(data: DisplayData): Promise<number[]> {
|
|
84
|
+
drawToCanvas(data);
|
|
85
|
+
return canvasToPngBytes(canvas!);
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateStartUpPageContainer,
|
|
3
|
+
RebuildPageContainer,
|
|
4
|
+
ImageContainerProperty,
|
|
5
|
+
TextContainerProperty,
|
|
6
|
+
} from '@evenrealities/even_hub_sdk';
|
|
7
|
+
import { MAIN_SLOT, dummySlot } from './layout';
|
|
8
|
+
|
|
9
|
+
function imageContainer(): ImageContainerProperty {
|
|
10
|
+
return new ImageContainerProperty({
|
|
11
|
+
containerID: MAIN_SLOT.id,
|
|
12
|
+
containerName: MAIN_SLOT.name,
|
|
13
|
+
xPosition: MAIN_SLOT.x,
|
|
14
|
+
yPosition: MAIN_SLOT.y,
|
|
15
|
+
width: MAIN_SLOT.w,
|
|
16
|
+
height: MAIN_SLOT.h,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function eventCaptureContainer(): TextContainerProperty {
|
|
21
|
+
return new TextContainerProperty({
|
|
22
|
+
containerID: 2,
|
|
23
|
+
containerName: 'events',
|
|
24
|
+
xPosition: MAIN_SLOT.x,
|
|
25
|
+
yPosition: MAIN_SLOT.y,
|
|
26
|
+
width: MAIN_SLOT.w,
|
|
27
|
+
height: MAIN_SLOT.h,
|
|
28
|
+
borderWidth: 0,
|
|
29
|
+
borderColor: 0,
|
|
30
|
+
borderRdaius: 0,
|
|
31
|
+
paddingLength: 0,
|
|
32
|
+
content: '',
|
|
33
|
+
isEventCapture: 1,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dummy(): TextContainerProperty {
|
|
38
|
+
const s = dummySlot(2);
|
|
39
|
+
return new TextContainerProperty({
|
|
40
|
+
containerID: s.id,
|
|
41
|
+
containerName: s.name,
|
|
42
|
+
xPosition: s.x,
|
|
43
|
+
yPosition: s.y,
|
|
44
|
+
width: s.w,
|
|
45
|
+
height: s.h,
|
|
46
|
+
borderWidth: 0,
|
|
47
|
+
borderColor: 0,
|
|
48
|
+
borderRdaius: 0,
|
|
49
|
+
paddingLength: 0,
|
|
50
|
+
content: '',
|
|
51
|
+
isEventCapture: 0,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function composeStartupPage(): CreateStartUpPageContainer {
|
|
56
|
+
return new CreateStartUpPageContainer({
|
|
57
|
+
containerTotalNum: 3,
|
|
58
|
+
imageObject: [imageContainer()],
|
|
59
|
+
textObject: [eventCaptureContainer(), dummy()],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function composeRebuildPage(): RebuildPageContainer {
|
|
64
|
+
return new RebuildPageContainer({
|
|
65
|
+
containerTotalNum: 3,
|
|
66
|
+
imageObject: [imageContainer()],
|
|
67
|
+
textObject: [eventCaptureContainer(), dummy()],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const TAP_COOLDOWN_MS = 220;
|
|
2
|
+
const TAP_DUPLICATE_DEBOUNCE_MS = 90;
|
|
3
|
+
const DOUBLE_TAP_DUPLICATE_DEBOUNCE_MS = 140;
|
|
4
|
+
const SCROLL_SUPPRESS_AFTER_TAP_MS = 110;
|
|
5
|
+
|
|
6
|
+
// Tuned for G2 hardware — prevents double-scroll from duplicate events
|
|
7
|
+
const SAME_DIRECTION_DEBOUNCE_MS = 350;
|
|
8
|
+
const DIRECTION_CHANGE_DEBOUNCE_MS = 50;
|
|
9
|
+
const SCROLL_SUPPRESS_AFTER_TEXT_MS = 80;
|
|
10
|
+
|
|
11
|
+
let lastTapTime = 0;
|
|
12
|
+
let lastTapKind: 'tap' | 'double' | null = null;
|
|
13
|
+
|
|
14
|
+
export function tryConsumeTap(kind: 'tap' | 'double'): boolean {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const elapsed = now - lastTapTime;
|
|
17
|
+
const duplicateMs =
|
|
18
|
+
kind === 'double' ? DOUBLE_TAP_DUPLICATE_DEBOUNCE_MS : TAP_DUPLICATE_DEBOUNCE_MS;
|
|
19
|
+
|
|
20
|
+
if (kind === lastTapKind && elapsed < duplicateMs) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (elapsed < TAP_COOLDOWN_MS && lastTapKind !== null) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
lastTapTime = now;
|
|
29
|
+
lastTapKind = kind;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isScrollSuppressed(): boolean {
|
|
34
|
+
return Date.now() - lastTapTime < SCROLL_SUPPRESS_AFTER_TAP_MS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let lastScrollTime = 0;
|
|
38
|
+
let lastScrollDir: 'prev' | 'next' | null = null;
|
|
39
|
+
let textUpdateTime = 0;
|
|
40
|
+
|
|
41
|
+
/** Call after every text container update to suppress spurious G2 scroll events */
|
|
42
|
+
export function notifyTextUpdate(): void {
|
|
43
|
+
textUpdateTime = Date.now();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isScrollDebounced(direction: 'prev' | 'next'): boolean {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
|
|
49
|
+
// Suppress scrolls briefly after a text update (G2 re-layout fires spurious events)
|
|
50
|
+
if (now - textUpdateTime < SCROLL_SUPPRESS_AFTER_TEXT_MS) return true;
|
|
51
|
+
|
|
52
|
+
const threshold =
|
|
53
|
+
direction === lastScrollDir ? SAME_DIRECTION_DEBOUNCE_MS : DIRECTION_CHANGE_DEBOUNCE_MS;
|
|
54
|
+
|
|
55
|
+
if (now - lastScrollTime < threshold) return true;
|
|
56
|
+
|
|
57
|
+
lastScrollTime = now;
|
|
58
|
+
lastScrollDir = direction;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
package/glasses/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// even-g2-sdk — Shared library for Even Realities G2 glasses apps
|
|
2
|
+
// Import individual modules via deep paths: 'even-g2-sdk/types', 'even-g2-sdk/bridge', etc.
|
|
3
|
+
|
|
4
|
+
export * from './types';
|
|
5
|
+
export * from './action-bar';
|
|
6
|
+
export * from './text-utils';
|
|
7
|
+
export * from './timer-display';
|
|
8
|
+
export * from './gestures';
|
|
9
|
+
export * from './text-clean';
|
|
10
|
+
export * from './paginate-text';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
let audioCtx: AudioContext | null = null;
|
|
2
|
+
let oscillator: OscillatorNode | null = null;
|
|
3
|
+
let lockPromise: Promise<unknown> | null = null;
|
|
4
|
+
|
|
5
|
+
export function activateKeepAlive(lockName = 'evenglass_keep_alive'): void {
|
|
6
|
+
try {
|
|
7
|
+
audioCtx = new AudioContext();
|
|
8
|
+
oscillator = audioCtx.createOscillator();
|
|
9
|
+
oscillator.frequency.value = 1;
|
|
10
|
+
const gain = audioCtx.createGain();
|
|
11
|
+
gain.gain.value = 0.001;
|
|
12
|
+
oscillator.connect(gain);
|
|
13
|
+
gain.connect(audioCtx.destination);
|
|
14
|
+
oscillator.start();
|
|
15
|
+
} catch { /* ignore */ }
|
|
16
|
+
|
|
17
|
+
if (navigator.locks) {
|
|
18
|
+
lockPromise = navigator.locks.request(lockName, () => {
|
|
19
|
+
return new Promise<void>(() => {});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deactivateKeepAlive(): void {
|
|
25
|
+
oscillator?.stop();
|
|
26
|
+
audioCtx?.close();
|
|
27
|
+
oscillator = null;
|
|
28
|
+
audioCtx = null;
|
|
29
|
+
lockPromise = null;
|
|
30
|
+
}
|