even-toolkit 1.1.0 → 1.1.1
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/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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GlassAction } from './types';
|
|
2
|
+
|
|
3
|
+
export function bindKeyboard(dispatch: (action: GlassAction) => void): () => void {
|
|
4
|
+
function isInteractive(el: HTMLElement): boolean {
|
|
5
|
+
const tag = el.tagName;
|
|
6
|
+
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return true;
|
|
7
|
+
if (tag === 'BUTTON' || tag === 'A') return true;
|
|
8
|
+
if (el.closest('button, a, [role="button"]')) return true;
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
13
|
+
const tag = (e.target as HTMLElement).tagName;
|
|
14
|
+
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
|
15
|
+
|
|
16
|
+
switch (e.key) {
|
|
17
|
+
case 'ArrowUp':
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
dispatch({ type: 'HIGHLIGHT_MOVE', direction: 'up' });
|
|
20
|
+
break;
|
|
21
|
+
case 'ArrowDown':
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
dispatch({ type: 'HIGHLIGHT_MOVE', direction: 'down' });
|
|
24
|
+
break;
|
|
25
|
+
case 'Enter':
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
dispatch({ type: 'SELECT_HIGHLIGHTED' });
|
|
28
|
+
break;
|
|
29
|
+
case 'Escape':
|
|
30
|
+
case 'Backspace':
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
dispatch({ type: 'GO_BACK' });
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Mouse wheel on the glasses simulator panel for scroll navigation
|
|
38
|
+
let lastWheelTime = 0;
|
|
39
|
+
const WHEEL_DEBOUNCE_MS = 250;
|
|
40
|
+
|
|
41
|
+
const wheelHandler = (e: WheelEvent) => {
|
|
42
|
+
// Only trigger on the simulator panel (monospace pre/div), not on the web app content
|
|
43
|
+
const target = e.target as HTMLElement;
|
|
44
|
+
if (isInteractive(target)) return;
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (now - lastWheelTime < WHEEL_DEBOUNCE_MS) return;
|
|
48
|
+
lastWheelTime = now;
|
|
49
|
+
|
|
50
|
+
if (e.deltaY > 0) {
|
|
51
|
+
dispatch({ type: 'HIGHLIGHT_MOVE', direction: 'down' });
|
|
52
|
+
} else if (e.deltaY < 0) {
|
|
53
|
+
dispatch({ type: 'HIGHLIGHT_MOVE', direction: 'up' });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
document.addEventListener('keydown', keyHandler);
|
|
58
|
+
document.addEventListener('wheel', wheelHandler, { passive: true });
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener('keydown', keyHandler);
|
|
62
|
+
document.removeEventListener('wheel', wheelHandler);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ── G2 display dimensions ──
|
|
2
|
+
|
|
3
|
+
export const DISPLAY_W = 576;
|
|
4
|
+
export const DISPLAY_H = 288;
|
|
5
|
+
export const G2_IMAGE_MAX_W = 200;
|
|
6
|
+
export const G2_IMAGE_MAX_H = 100;
|
|
7
|
+
|
|
8
|
+
// ── Image tile slots (for chart layout: 3 tiles across the top) ──
|
|
9
|
+
|
|
10
|
+
export interface TileSlot {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
crop: { sx: number; sy: number; sw: number; sh: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TILE_1: TileSlot = {
|
|
21
|
+
id: 2, name: 'tile-1',
|
|
22
|
+
x: 0, y: 0, w: G2_IMAGE_MAX_W, h: G2_IMAGE_MAX_H,
|
|
23
|
+
crop: { sx: 0, sy: 0, sw: 200, sh: 100 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const TILE_2: TileSlot = {
|
|
27
|
+
id: 3, name: 'tile-2',
|
|
28
|
+
x: 200, y: 0, w: G2_IMAGE_MAX_W, h: G2_IMAGE_MAX_H,
|
|
29
|
+
crop: { sx: 200, sy: 0, sw: 200, sh: 100 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const TILE_3: TileSlot = {
|
|
33
|
+
id: 4, name: 'tile-3',
|
|
34
|
+
x: 400, y: 0, w: G2_IMAGE_MAX_W, h: G2_IMAGE_MAX_H,
|
|
35
|
+
crop: { sx: 400, sy: 0, sw: 176, sh: 100 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const IMAGE_TILES = [TILE_1, TILE_2, TILE_3];
|
|
39
|
+
|
|
40
|
+
// ── Splash layout (1 centered image) ──
|
|
41
|
+
|
|
42
|
+
export const SPLASH_IMG = {
|
|
43
|
+
id: 2,
|
|
44
|
+
name: 'splash',
|
|
45
|
+
x: Math.floor((DISPLAY_W - G2_IMAGE_MAX_W) / 2),
|
|
46
|
+
y: Math.floor((DISPLAY_H - G2_IMAGE_MAX_H) / 2),
|
|
47
|
+
w: G2_IMAGE_MAX_W,
|
|
48
|
+
h: G2_IMAGE_MAX_H,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Text-only layout (full screen) ──
|
|
52
|
+
|
|
53
|
+
export const TEXT_FULL = {
|
|
54
|
+
id: 1,
|
|
55
|
+
name: 'main',
|
|
56
|
+
x: 0, y: 0,
|
|
57
|
+
w: DISPLAY_W, h: DISPLAY_H,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ── Viewport size per timeframe (ideal candle count for 576px chart) ──
|
|
61
|
+
|
|
62
|
+
export const VIEWPORT_PER_RESOLUTION: Record<string, number> = {
|
|
63
|
+
'1': 40, // 40 min
|
|
64
|
+
'5': 32, // ~2.5 hours
|
|
65
|
+
'15': 28, // ~7 hours
|
|
66
|
+
'60': 24, // 1 day
|
|
67
|
+
'D': 28, // ~1.5 months
|
|
68
|
+
'W': 20, // ~5 months
|
|
69
|
+
'M': 16, // ~1.5 years
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Chart canvas (rendered offscreen, then split into tiles) ──
|
|
73
|
+
|
|
74
|
+
export const CHART_CANVAS_W = 576;
|
|
75
|
+
export const CHART_CANVAS_H = 100;
|
|
76
|
+
|
|
77
|
+
// ── Chart layout text container (below images) ──
|
|
78
|
+
|
|
79
|
+
export const CHART_TEXT = {
|
|
80
|
+
id: 1,
|
|
81
|
+
name: 'main',
|
|
82
|
+
x: 0,
|
|
83
|
+
y: G2_IMAGE_MAX_H,
|
|
84
|
+
w: DISPLAY_W,
|
|
85
|
+
h: DISPLAY_H - G2_IMAGE_MAX_H,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── Default column positions for 3-column layout (apps can override) ──
|
|
89
|
+
|
|
90
|
+
export const DEFAULT_COLUMNS = {
|
|
91
|
+
col1X: 0,
|
|
92
|
+
col1W: 192,
|
|
93
|
+
col2X: 192,
|
|
94
|
+
col2W: 192,
|
|
95
|
+
col3X: 384,
|
|
96
|
+
col3W: DISPLAY_W - 384,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ── Legacy compat ──
|
|
100
|
+
|
|
101
|
+
export const MAIN_SLOT = {
|
|
102
|
+
id: 1,
|
|
103
|
+
name: 'main',
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0,
|
|
106
|
+
w: DISPLAY_W,
|
|
107
|
+
h: DISPLAY_H - 2,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const CONTAINER_IDS = [1, 2, 3] as const;
|
|
111
|
+
|
|
112
|
+
export function dummySlot(index: number) {
|
|
113
|
+
return {
|
|
114
|
+
id: CONTAINER_IDS[index]!,
|
|
115
|
+
name: `d-${index + 1}`,
|
|
116
|
+
x: 0,
|
|
117
|
+
y: DISPLAY_H - 2,
|
|
118
|
+
w: 1,
|
|
119
|
+
h: 1,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text pagination utilities for G2 glasses display.
|
|
3
|
+
*
|
|
4
|
+
* Word-wraps text into lines, groups lines into pages, and generates
|
|
5
|
+
* page indicators — useful for any app that shows multi-page text content.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { wordWrap, paginateText, pageIndicator } from 'even-toolkit/paginate-text';
|
|
9
|
+
*
|
|
10
|
+
* const lines = wordWrap(longText, 46);
|
|
11
|
+
* const pages = paginateText(longText, 46, 9);
|
|
12
|
+
* const label = pageIndicator(2, pages.length); // "Page 3/15"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Word-wrap text to a maximum line length, breaking at word boundaries.
|
|
17
|
+
* Long words that exceed maxLen are force-split.
|
|
18
|
+
*
|
|
19
|
+
* @param text The text to wrap
|
|
20
|
+
* @param maxLen Maximum characters per line (default 46 — fits G2 display)
|
|
21
|
+
* @returns Array of wrapped lines
|
|
22
|
+
*/
|
|
23
|
+
export function wordWrap(text: string, maxLen = 46): string[] {
|
|
24
|
+
if (!text) return [''];
|
|
25
|
+
const words = text.split(/\s+/);
|
|
26
|
+
const result: string[] = [];
|
|
27
|
+
let current = '';
|
|
28
|
+
|
|
29
|
+
for (const word of words) {
|
|
30
|
+
if (!current) {
|
|
31
|
+
current = word;
|
|
32
|
+
} else if (current.length + 1 + word.length <= maxLen) {
|
|
33
|
+
current += ' ' + word;
|
|
34
|
+
} else {
|
|
35
|
+
result.push(current);
|
|
36
|
+
current = word;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (current) result.push(current);
|
|
40
|
+
|
|
41
|
+
// Force-split lines that are still too long
|
|
42
|
+
const final: string[] = [];
|
|
43
|
+
for (const ln of result) {
|
|
44
|
+
if (ln.length <= maxLen) {
|
|
45
|
+
final.push(ln);
|
|
46
|
+
} else {
|
|
47
|
+
for (let i = 0; i < ln.length; i += maxLen) {
|
|
48
|
+
final.push(ln.slice(i, i + maxLen));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return final.length > 0 ? final : [''];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Paginate text into pages of fixed line count.
|
|
58
|
+
* First word-wraps, then groups into pages.
|
|
59
|
+
*
|
|
60
|
+
* @param text The text to paginate
|
|
61
|
+
* @param charsPerLine Max characters per line (default 46)
|
|
62
|
+
* @param linesPerPage Lines per page (default 9)
|
|
63
|
+
* @returns Array of pages, each page is an array of line strings
|
|
64
|
+
*/
|
|
65
|
+
export function paginateText(text: string, charsPerLine = 46, linesPerPage = 9): string[][] {
|
|
66
|
+
const lines = wordWrap(text, charsPerLine);
|
|
67
|
+
const pages: string[][] = [];
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i += linesPerPage) {
|
|
70
|
+
pages.push(lines.slice(i, i + linesPerPage));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return pages.length > 0 ? pages : [['']];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a page indicator string, e.g. "Page 3/15".
|
|
78
|
+
*
|
|
79
|
+
* @param currentIndex Zero-based current page index
|
|
80
|
+
* @param totalPages Total number of pages
|
|
81
|
+
* @returns Human-readable page indicator
|
|
82
|
+
*/
|
|
83
|
+
export function pageIndicator(currentIndex: number, totalPages: number): string {
|
|
84
|
+
return `Page ${currentIndex + 1}/${totalPages}`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** PNG encoding: 16-color (4-bit) indexed PNG via UPNG — smallest possible files for G2. */
|
|
2
|
+
import UPNG from 'upng-js';
|
|
3
|
+
|
|
4
|
+
function fnv32a(bytes: Uint8Array): number {
|
|
5
|
+
let hash = 0x811c9dc5;
|
|
6
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
7
|
+
hash ^= bytes[i]!;
|
|
8
|
+
hash = Math.imul(hash, 0x01000193);
|
|
9
|
+
}
|
|
10
|
+
return hash >>> 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EncodedTile {
|
|
14
|
+
bytes: Uint8Array;
|
|
15
|
+
hash: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Cache tile canvases
|
|
19
|
+
const tileCanvasCache = new Map<string, { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D }>();
|
|
20
|
+
|
|
21
|
+
function getTileCtx(key: string, w: number, h: number): CanvasRenderingContext2D {
|
|
22
|
+
let cached = tileCanvasCache.get(key);
|
|
23
|
+
if (!cached || cached.canvas.width !== w || cached.canvas.height !== h) {
|
|
24
|
+
const canvas = document.createElement('canvas');
|
|
25
|
+
canvas.width = w; canvas.height = h;
|
|
26
|
+
cached = { canvas, ctx: canvas.getContext('2d')! };
|
|
27
|
+
tileCanvasCache.set(key, cached);
|
|
28
|
+
}
|
|
29
|
+
return cached.ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reusable RGBA buffer for UPNG encode
|
|
33
|
+
let rgbaBuf: Uint8Array | null = null;
|
|
34
|
+
let rgbaBufSize = 0;
|
|
35
|
+
|
|
36
|
+
function getRgbaBuf(size: number): Uint8Array {
|
|
37
|
+
if (!rgbaBuf || rgbaBufSize < size) {
|
|
38
|
+
rgbaBuf = new Uint8Array(size);
|
|
39
|
+
rgbaBufSize = size;
|
|
40
|
+
}
|
|
41
|
+
return rgbaBuf;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
function encodeTile(
|
|
46
|
+
canvas: HTMLCanvasElement,
|
|
47
|
+
sx: number, sy: number, sw: number, sh: number,
|
|
48
|
+
tw: number, th: number,
|
|
49
|
+
key: string,
|
|
50
|
+
): EncodedTile {
|
|
51
|
+
const ctx = getTileCtx(key, tw, th);
|
|
52
|
+
ctx.fillStyle = '#000000';
|
|
53
|
+
ctx.fillRect(0, 0, tw, th);
|
|
54
|
+
const dw = Math.min(sw, tw), dh = Math.min(sh, th);
|
|
55
|
+
ctx.drawImage(canvas, sx, sy, dw, dh, 0, 0, dw, dh);
|
|
56
|
+
|
|
57
|
+
const imgData = ctx.getImageData(0, 0, tw, th);
|
|
58
|
+
const pixels = imgData.data;
|
|
59
|
+
const pc = tw * th;
|
|
60
|
+
|
|
61
|
+
// Quantize to 16-level greyscale for 4-bit indexed PNG
|
|
62
|
+
const buf = getRgbaBuf(pc * 4);
|
|
63
|
+
for (let i = 0; i < pc; i++) {
|
|
64
|
+
const si = i * 4;
|
|
65
|
+
const lum = Math.round(0.299 * pixels[si]! + 0.587 * pixels[si + 1]! + 0.114 * pixels[si + 2]!);
|
|
66
|
+
const idx = Math.min(15, Math.round(lum / 17));
|
|
67
|
+
const v = idx * 17;
|
|
68
|
+
buf[si] = v; buf[si + 1] = v; buf[si + 2] = v; buf[si + 3] = 255;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 16-color indexed PNG
|
|
72
|
+
const pngBuf = UPNG.encode([buf.buffer.slice(0, pc * 4) as ArrayBuffer], tw, th, 16);
|
|
73
|
+
const bytes = new Uint8Array(pngBuf);
|
|
74
|
+
return { bytes, hash: fnv32a(bytes) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Encode all tiles from a source canvas. */
|
|
78
|
+
export function encodeTilesBatch(
|
|
79
|
+
canvas: HTMLCanvasElement,
|
|
80
|
+
tiles: Array<{ crop: { sx: number; sy: number; sw: number; sh: number }; name: string }>,
|
|
81
|
+
tw: number, th: number,
|
|
82
|
+
): EncodedTile[] {
|
|
83
|
+
return tiles.map((tile) =>
|
|
84
|
+
encodeTile(canvas, tile.crop.sx, tile.crop.sy, tile.crop.sw, tile.crop.sh, tw, th, tile.name)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reset cache (no-op now, kept for API compat). */
|
|
89
|
+
export function resetTileCache(): void {}
|
|
90
|
+
|
|
91
|
+
/** Backward-compat: encode full canvas to PNG bytes (number[] for SDK). */
|
|
92
|
+
export async function canvasToPngBytes(canvas: HTMLCanvasElement): Promise<number[]> {
|
|
93
|
+
const w = canvas.width;
|
|
94
|
+
const h = canvas.height;
|
|
95
|
+
const tile = encodeTile(canvas, 0, 0, w, h, w, h, '__full');
|
|
96
|
+
return Array.from(tile.bytes);
|
|
97
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splash screen system for G2 glasses apps.
|
|
3
|
+
*
|
|
4
|
+
* Renders a canvas-based splash image to G2 image tiles with configurable
|
|
5
|
+
* timing, tile layout, custom positioning, and a render callback.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const splash = createSplash({
|
|
9
|
+
* render: (ctx, w, h) => {
|
|
10
|
+
* ctx.fillStyle = '#e0e0e0';
|
|
11
|
+
* ctx.font = 'bold 16px monospace';
|
|
12
|
+
* ctx.textAlign = 'center';
|
|
13
|
+
* ctx.fillText('MyApp', w/2, h/2);
|
|
14
|
+
* },
|
|
15
|
+
* tiles: 1,
|
|
16
|
+
* minTimeMs: 2000,
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // In useGlasses config:
|
|
20
|
+
* useGlasses({ ..., splash });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { G2_IMAGE_MAX_W, G2_IMAGE_MAX_H, DISPLAY_W, DISPLAY_H, IMAGE_TILES } from './layout';
|
|
24
|
+
import { encodeTilesBatch } from './png-utils';
|
|
25
|
+
|
|
26
|
+
export interface TilePosition {
|
|
27
|
+
/** X position on the G2 display (0-576). Default: auto from tile index */
|
|
28
|
+
x: number;
|
|
29
|
+
/** Y position on the G2 display (0-288). Default: 0 */
|
|
30
|
+
y: number;
|
|
31
|
+
/** Tile width. Default: 200 (G2_IMAGE_MAX_W) */
|
|
32
|
+
w?: number;
|
|
33
|
+
/** Tile height. Default: 100 (G2_IMAGE_MAX_H) */
|
|
34
|
+
h?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SplashConfig {
|
|
38
|
+
/**
|
|
39
|
+
* Render callback — draw your splash on the provided canvas context.
|
|
40
|
+
* The canvas is pre-filled with black.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx Canvas 2D context
|
|
43
|
+
* @param w Canvas width in pixels
|
|
44
|
+
* @param h Canvas height in pixels
|
|
45
|
+
*/
|
|
46
|
+
render: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Number of image tiles to use (1-3).
|
|
50
|
+
* - 1: single 200x100 tile
|
|
51
|
+
* - 2: 400x100 across 2 tiles (horizontal) or 200x200 (vertical)
|
|
52
|
+
* - 3: 576x100 across 3 tiles (full width)
|
|
53
|
+
* Default: 1
|
|
54
|
+
*/
|
|
55
|
+
tiles?: 1 | 2 | 3;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tile layout direction.
|
|
59
|
+
* - 'horizontal': tiles placed side-by-side (default)
|
|
60
|
+
* - 'vertical': tiles stacked top-to-bottom
|
|
61
|
+
*/
|
|
62
|
+
tileLayout?: 'horizontal' | 'vertical';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Custom tile positions on the G2 display.
|
|
66
|
+
* Override where each tile is placed. Array length must match `tiles` count.
|
|
67
|
+
*
|
|
68
|
+
* Example — center a single tile:
|
|
69
|
+
* tilePositions: [{ x: 188, y: 94 }] // centered on 576x288
|
|
70
|
+
*
|
|
71
|
+
* Example — 2 tiles stacked vertically:
|
|
72
|
+
* tilePositions: [{ x: 0, y: 0 }, { x: 0, y: 100 }]
|
|
73
|
+
*
|
|
74
|
+
* Default: tiles placed side-by-side at y=0 (standard layout)
|
|
75
|
+
*/
|
|
76
|
+
tilePositions?: TilePosition[];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Canvas size override. By default: (tiles * 200) x 100 for horizontal,
|
|
80
|
+
* 200 x (tiles * 100) for vertical.
|
|
81
|
+
* Use this when you need a different aspect ratio or resolution.
|
|
82
|
+
*/
|
|
83
|
+
canvasSize?: { w: number; h: number };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Minimum time the splash should be visible (ms).
|
|
87
|
+
* `waitMinTime()` will delay until this time has passed since `show()`.
|
|
88
|
+
* Default: 2000
|
|
89
|
+
*/
|
|
90
|
+
minTimeMs?: number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Maximum time before the splash is forcefully dismissed (ms).
|
|
94
|
+
* Set to 0 for no max. Default: 5000
|
|
95
|
+
*/
|
|
96
|
+
maxTimeMs?: number;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Menu text shown below the image (in the text container).
|
|
100
|
+
* Default: empty string (hidden)
|
|
101
|
+
*/
|
|
102
|
+
menuText?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SplashHandle {
|
|
106
|
+
/** Show the splash on the glasses. Returns when the image is sent. */
|
|
107
|
+
show: (bridge: SplashBridge) => Promise<void>;
|
|
108
|
+
/** Wait until minTimeMs has elapsed since show(). Resolves immediately if already elapsed. */
|
|
109
|
+
waitMinTime: () => Promise<void>;
|
|
110
|
+
/** Clear extra splash tiles (send black). Call after waitMinTime for seamless transition. */
|
|
111
|
+
clearExtras: (bridge: SplashBridge) => Promise<void>;
|
|
112
|
+
/** Check if the splash is currently showing. */
|
|
113
|
+
isShowing: () => boolean;
|
|
114
|
+
/** Get encoded tile data (for apps that manage their own bridge). */
|
|
115
|
+
getTiles: () => { id: number; name: string; bytes: Uint8Array; x: number; y: number; w: number; h: number }[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Minimal bridge interface needed for splash (subset of EvenHubBridge). */
|
|
119
|
+
export interface SplashBridge {
|
|
120
|
+
showHomePage: (menuText: string, imageTiles?: { id: number; name: string; x: number; y: number; w: number; h: number }[]) => Promise<void>;
|
|
121
|
+
sendImage: (containerId: number, containerName: string, pngBytes: Uint8Array) => Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Preset tile positions for common layouts.
|
|
126
|
+
*/
|
|
127
|
+
export const TILE_PRESETS = {
|
|
128
|
+
/** Single tile centered on the G2 display */
|
|
129
|
+
centered1: [
|
|
130
|
+
{ x: Math.floor((DISPLAY_W - G2_IMAGE_MAX_W) / 2), y: Math.floor((DISPLAY_H - G2_IMAGE_MAX_H) / 2) },
|
|
131
|
+
] as TilePosition[],
|
|
132
|
+
|
|
133
|
+
/** Single tile top-center */
|
|
134
|
+
topCenter1: [
|
|
135
|
+
{ x: Math.floor((DISPLAY_W - G2_IMAGE_MAX_W) / 2), y: 20 },
|
|
136
|
+
] as TilePosition[],
|
|
137
|
+
|
|
138
|
+
/** 2 tiles side-by-side, centered vertically */
|
|
139
|
+
centered2: [
|
|
140
|
+
{ x: Math.floor((DISPLAY_W - 400) / 2), y: Math.floor((DISPLAY_H - G2_IMAGE_MAX_H) / 2) },
|
|
141
|
+
{ x: Math.floor((DISPLAY_W - 400) / 2) + G2_IMAGE_MAX_W, y: Math.floor((DISPLAY_H - G2_IMAGE_MAX_H) / 2) },
|
|
142
|
+
] as TilePosition[],
|
|
143
|
+
|
|
144
|
+
/** 2 tiles stacked vertically, centered horizontally, near top */
|
|
145
|
+
topCenterVertical2: [
|
|
146
|
+
{ x: Math.floor((DISPLAY_W - G2_IMAGE_MAX_W) / 2), y: 20 },
|
|
147
|
+
{ x: Math.floor((DISPLAY_W - G2_IMAGE_MAX_W) / 2), y: 20 + G2_IMAGE_MAX_H },
|
|
148
|
+
] as TilePosition[],
|
|
149
|
+
|
|
150
|
+
/** 3 tiles full width at top */
|
|
151
|
+
fullWidthTop: [
|
|
152
|
+
{ x: 0, y: 0 },
|
|
153
|
+
{ x: G2_IMAGE_MAX_W, y: 0 },
|
|
154
|
+
{ x: G2_IMAGE_MAX_W * 2, y: 0 },
|
|
155
|
+
] as TilePosition[],
|
|
156
|
+
|
|
157
|
+
/** Default: standard side-by-side at y=0 */
|
|
158
|
+
default: undefined,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function createSplash(config: SplashConfig): SplashHandle {
|
|
162
|
+
const tileCount = config.tiles ?? 1;
|
|
163
|
+
const minTime = config.minTimeMs ?? 2000;
|
|
164
|
+
const maxTime = config.maxTimeMs ?? 5000;
|
|
165
|
+
const menuText = config.menuText ?? '';
|
|
166
|
+
const positions = config.tilePositions;
|
|
167
|
+
const vertical = config.tileLayout === 'vertical';
|
|
168
|
+
|
|
169
|
+
const tileW = G2_IMAGE_MAX_W; // 200
|
|
170
|
+
const tileH = G2_IMAGE_MAX_H; // 100
|
|
171
|
+
const canvasW = config.canvasSize?.w ?? (vertical ? tileW : tileCount * tileW);
|
|
172
|
+
const canvasH = config.canvasSize?.h ?? (vertical ? tileCount * tileH : tileH);
|
|
173
|
+
|
|
174
|
+
let showTime = 0;
|
|
175
|
+
let showing = false;
|
|
176
|
+
let encodedTiles: { id: number; name: string; bytes: Uint8Array; x: number; y: number; w: number; h: number }[] = [];
|
|
177
|
+
|
|
178
|
+
function renderAndEncode(): void {
|
|
179
|
+
const canvas = document.createElement('canvas');
|
|
180
|
+
canvas.width = canvasW;
|
|
181
|
+
canvas.height = canvasH;
|
|
182
|
+
const ctx = canvas.getContext('2d')!;
|
|
183
|
+
|
|
184
|
+
// Black background
|
|
185
|
+
ctx.fillStyle = '#000000';
|
|
186
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
187
|
+
|
|
188
|
+
// App-specific rendering
|
|
189
|
+
config.render(ctx, canvasW, canvasH);
|
|
190
|
+
|
|
191
|
+
// Encode each tile
|
|
192
|
+
encodedTiles = [];
|
|
193
|
+
for (let i = 0; i < tileCount; i++) {
|
|
194
|
+
const slot = IMAGE_TILES[i]!;
|
|
195
|
+
const pos = positions?.[i];
|
|
196
|
+
const cropX = vertical ? 0 : i * tileW;
|
|
197
|
+
const cropY = vertical ? i * tileH : 0;
|
|
198
|
+
const cropW = Math.min(tileW, canvasW - cropX);
|
|
199
|
+
const cropH = Math.min(tileH, canvasH - cropY);
|
|
200
|
+
const crop = { sx: cropX, sy: cropY, sw: cropW, sh: cropH };
|
|
201
|
+
const enc = encodeTilesBatch(canvas, [{ crop, name: slot.name }], tileW, tileH)[0]!;
|
|
202
|
+
|
|
203
|
+
encodedTiles.push({
|
|
204
|
+
id: slot.id,
|
|
205
|
+
name: slot.name,
|
|
206
|
+
bytes: enc.bytes,
|
|
207
|
+
x: pos?.x ?? slot.x,
|
|
208
|
+
y: pos?.y ?? slot.y,
|
|
209
|
+
w: pos?.w ?? tileW,
|
|
210
|
+
h: pos?.h ?? tileH,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Fill remaining tiles with black
|
|
215
|
+
if (tileCount < 3) {
|
|
216
|
+
const black = document.createElement('canvas');
|
|
217
|
+
black.width = tileW;
|
|
218
|
+
black.height = tileH;
|
|
219
|
+
const bctx = black.getContext('2d')!;
|
|
220
|
+
bctx.fillStyle = '#000000';
|
|
221
|
+
bctx.fillRect(0, 0, tileW, tileH);
|
|
222
|
+
const blackEnc = encodeTilesBatch(black, [{ crop: { sx: 0, sy: 0, sw: tileW, sh: tileH }, name: 'black' }], tileW, tileH)[0]!;
|
|
223
|
+
|
|
224
|
+
for (let i = tileCount; i < 3; i++) {
|
|
225
|
+
const slot = IMAGE_TILES[i]!;
|
|
226
|
+
encodedTiles.push({
|
|
227
|
+
id: slot.id,
|
|
228
|
+
name: slot.name,
|
|
229
|
+
bytes: blackEnc.bytes,
|
|
230
|
+
x: slot.x,
|
|
231
|
+
y: slot.y,
|
|
232
|
+
w: tileW,
|
|
233
|
+
h: tileH,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
async show(bridge: SplashBridge): Promise<void> {
|
|
241
|
+
renderAndEncode();
|
|
242
|
+
showTime = Date.now();
|
|
243
|
+
showing = true;
|
|
244
|
+
|
|
245
|
+
// Show the home layout with all image tiles
|
|
246
|
+
const imageTiles = encodedTiles
|
|
247
|
+
.filter((_, i) => i < tileCount)
|
|
248
|
+
.map(t => ({ id: t.id, name: t.name, x: t.x, y: t.y, w: t.w, h: t.h }));
|
|
249
|
+
await bridge.showHomePage(menuText, imageTiles);
|
|
250
|
+
|
|
251
|
+
// Send only the app tiles (not black padding tiles)
|
|
252
|
+
for (let i = 0; i < tileCount; i++) {
|
|
253
|
+
const tile = encodedTiles[i]!;
|
|
254
|
+
await bridge.sendImage(tile.id, tile.name, tile.bytes);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Auto-dismiss after maxTime
|
|
258
|
+
if (maxTime > 0) {
|
|
259
|
+
setTimeout(() => { showing = false; }, maxTime);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async waitMinTime(): Promise<void> {
|
|
264
|
+
const elapsed = Date.now() - showTime;
|
|
265
|
+
if (elapsed < minTime) {
|
|
266
|
+
await new Promise((r) => setTimeout(r, minTime - elapsed));
|
|
267
|
+
}
|
|
268
|
+
showing = false;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async clearExtras(bridge: SplashBridge): Promise<void> {
|
|
272
|
+
// Send black tiles for any extra splash tiles (e.g. tile 2 "Loading...")
|
|
273
|
+
// so they don't linger when transitioning to the home screen with fewer tiles.
|
|
274
|
+
if (encodedTiles.length === 0) return;
|
|
275
|
+
const black = document.createElement('canvas');
|
|
276
|
+
black.width = tileW;
|
|
277
|
+
black.height = tileH;
|
|
278
|
+
const bctx = black.getContext('2d')!;
|
|
279
|
+
bctx.fillStyle = '#000000';
|
|
280
|
+
bctx.fillRect(0, 0, tileW, tileH);
|
|
281
|
+
const blackEnc = encodeTilesBatch(black, [{ crop: { sx: 0, sy: 0, sw: tileW, sh: tileH }, name: 'black' }], tileW, tileH)[0]!;
|
|
282
|
+
// Clear tiles beyond what the home screen uses (tile index 1+ for vertical 2-tile splash)
|
|
283
|
+
for (let i = 1; i < encodedTiles.length; i++) {
|
|
284
|
+
const tile = encodedTiles[i]!;
|
|
285
|
+
await bridge.sendImage(tile.id, tile.name, blackEnc.bytes);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
isShowing(): boolean {
|
|
290
|
+
return showing;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
getTiles() {
|
|
294
|
+
if (encodedTiles.length === 0) renderAndEncode();
|
|
295
|
+
return encodedTiles;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text cleaning utilities for G2 glasses display.
|
|
3
|
+
*
|
|
4
|
+
* The G2 supports basic Latin, Latin-1 Supplement, and common symbols.
|
|
5
|
+
* These helpers strip emojis, unsupported Unicode, and normalize whitespace
|
|
6
|
+
* so text renders cleanly on the monospace G2 display.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { cleanForG2, normalizeWhitespace } from 'even-toolkit/text-clean';
|
|
10
|
+
* const safe = cleanForG2('Hello 🌍 World!'); // "Hello World!"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Match emoji ranges + misc symbols + dingbats + variation selectors
|
|
14
|
+
const EMOJI_RE = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2702}-\u{27B0}\u{231A}-\u{231B}\u{23E9}-\u{23F3}\u{23F8}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2614}-\u{2615}\u{2648}-\u{2653}\u{267F}\u{2693}\u{26A1}\u{26AA}-\u{26AB}\u{26BD}-\u{26BE}\u{26C4}-\u{26C5}\u{26CE}\u{26D4}\u{26EA}\u{26F2}-\u{26F3}\u{26F5}\u{26FA}\u{26FD}\u{2934}-\u{2935}\u{2B05}-\u{2B07}\u{2B1B}-\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}]/gu;
|
|
15
|
+
|
|
16
|
+
// G2 supports basic Latin, Latin-1 Supplement, and some common symbols.
|
|
17
|
+
// Strip anything outside printable ASCII + common Latin extended + box drawing.
|
|
18
|
+
const UNSUPPORTED_RE = /[^\x20-\x7E\u00A0-\u00FF\u2010-\u2027\u2030-\u205E\u2190-\u21FF\u2500-\u257F]/g;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Clean text for safe rendering on the G2 glasses display.
|
|
22
|
+
* Strips emojis, unsupported Unicode characters, and normalizes whitespace.
|
|
23
|
+
*/
|
|
24
|
+
export function cleanForG2(text: string): string {
|
|
25
|
+
return text
|
|
26
|
+
.replace(EMOJI_RE, '')
|
|
27
|
+
.replace(UNSUPPORTED_RE, '')
|
|
28
|
+
.replace(/\s+/g, ' ')
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize whitespace only (collapse multiple spaces/newlines, trim).
|
|
34
|
+
* Does not strip Unicode — use when the text source is already safe.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeWhitespace(text: string): string {
|
|
37
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
38
|
+
}
|