@glassly/engine 0.1.0-dev.118 → 0.1.0-dev.119

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.
@@ -18,6 +18,7 @@ import {SETTINGS, useSettingsStore} from "../stores/settings"
18
18
  import {useGlassesStore} from "../stores/glasses"
19
19
  import {DeviceTypes} from "../types"
20
20
  import {getEffectiveCapabilities} from "./effectiveCapabilities"
21
+ import {BgTimer} from "../utils/timers"
21
22
  import {
22
23
  degradeScene,
23
24
  diffScene,
@@ -37,16 +38,47 @@ const LOG_TAG = "SCENE_RENDERER"
37
38
  export type LegacyLayout = {layoutType: string; [key: string]: unknown}
38
39
 
39
40
  export type SceneEmitResult =
40
- /** A SceneFrame went to native. */
41
- | {kind: "scene"; degraded: boolean; dropped: string[]}
41
+ /**
42
+ * A SceneFrame was committed. `sent` is false when the frame stayed
43
+ * phone-side (dashboard covering main). `settleMs` is how long the device
44
+ * keeps animating after it has the frame: the longest transition among the
45
+ * elements this frame changed, 0 on devices that jump.
46
+ */
47
+ | {kind: "scene"; degraded: boolean; dropped: string[]; seq: number; sent: boolean; settleMs: number}
42
48
  /** Degrade path: caller routes this legacy layout through the existing pipeline (null layout ⇒ clear). */
43
49
  | {kind: "legacy"; layout: LegacyLayout | null; degraded: boolean; dropped: string[]}
44
50
  /** No display capabilities on the connected device. */
45
51
  | {kind: "no-display"}
46
52
 
53
+ /** How a frame ended up, as reported by native (or inferred from a later frame's report). */
54
+ export interface ScenePresentation {
55
+ /** The device had the frame's content (this frame or a later one that folded it in). */
56
+ presented: boolean
57
+ /** A later frame's report settled this one. */
58
+ superseded: boolean
59
+ reason?: string
60
+ }
61
+
62
+ /** Longest tween the G2 CFW encodes (255 frames at ~30 fps); nothing waits past it. */
63
+ const MAX_SETTLE_MS = 255 * 33
64
+
65
+ /** Longest transition among the elements that actually change on the device this frame. */
66
+ function settleTimeOf(elements: FrameElement[]): number {
67
+ let ms = 0
68
+ for (const el of elements) {
69
+ if (el.change !== "updated" && el.change !== "moved") continue
70
+ const t = el.transition?.durationMs ?? 0
71
+ if (t > ms) ms = t
72
+ }
73
+ return Math.min(ms, MAX_SETTLE_MS)
74
+ }
75
+
47
76
  class SceneRenderer {
48
77
  private static instance: SceneRenderer | null = null
49
78
  private store = new SceneStore()
79
+ private seqCounter = 0
80
+ private pending = new Map<number, {appId: string; resolve: (p: ScenePresentation) => void}>()
81
+ private presentedSub: {remove: () => void} | null = null
50
82
 
51
83
  public static getInstance(): SceneRenderer {
52
84
  if (!SceneRenderer.instance) {
@@ -226,12 +258,20 @@ class SceneRenderer {
226
258
  appId,
227
259
  view,
228
260
  sceneEpoch: this.store.currentEpoch(appId, view),
261
+ seq: ++this.seqCounter,
229
262
  elements: diffed,
230
263
  removed,
231
264
  canvas,
232
265
  }
233
- this.sendFrame(frame)
234
- return {kind: "scene", degraded: processed.degraded, dropped: processed.dropped}
266
+ const sent = this.sendFrame(frame)
267
+ return {
268
+ kind: "scene",
269
+ degraded: processed.degraded,
270
+ dropped: processed.dropped,
271
+ seq: frame.seq,
272
+ sent,
273
+ settleMs: caps.animation ? settleTimeOf(diffed) : 0,
274
+ }
235
275
  }
236
276
 
237
277
  /**
@@ -240,15 +280,72 @@ class SceneRenderer {
240
280
  * Returns false when nothing is retained.
241
281
  */
242
282
  public replayApp(appId: string, view: "main" | "dashboard"): boolean {
243
- const frame = this.store.buildReplayFrame(appId, view)
283
+ const frame = this.store.buildReplayFrame(appId, view, ++this.seqCounter)
244
284
  if (!frame) return false
245
285
  this.sendFrame(frame)
246
286
  return true
247
287
  }
248
288
 
249
- /** Forget an app's retained scenes (app stop). */
289
+ /** Forget an app's retained scenes (app stop). Frames still in flight will never show. */
250
290
  public clearApp(appId: string): void {
251
291
  this.store.clear(appId)
292
+ this.settlePending((p) => p.appId === appId, {presented: false, superseded: true, reason: "cleared"})
293
+ }
294
+
295
+ // ===========================================================================
296
+ // Presentation — native reports when the device has a frame
297
+ // ===========================================================================
298
+
299
+ /**
300
+ * Resolves once native reports frame `seq` on the device, or a later frame
301
+ * that folded it in. Never rejects; `timeoutMs` bounds a frame the device
302
+ * never acknowledges (link stalls, page rebuilding).
303
+ */
304
+ public whenPresented(seq: number, timeoutMs: number, appId: string): Promise<ScenePresentation> {
305
+ this.ensurePresentedListener()
306
+ return new Promise((resolve) => {
307
+ const timer = BgTimer.setTimeout(() => {
308
+ if (this.pending.delete(seq)) resolve({presented: false, superseded: false, reason: "no presentation report"})
309
+ }, timeoutMs)
310
+ this.pending.set(seq, {
311
+ appId,
312
+ resolve: (p) => {
313
+ BgTimer.clearTimeout(timer)
314
+ resolve(p)
315
+ },
316
+ })
317
+ })
318
+ }
319
+
320
+ private ensurePresentedListener(): void {
321
+ if (this.presentedSub) return
322
+ this.presentedSub = BluetoothSdk.addListener("scene_frame_presented", (event) => {
323
+ this.onFramePresented(event.seq, event.appId, event.presented, event.reason)
324
+ })
325
+ }
326
+
327
+ /** A report for `seq` settles it and every earlier frame (folded in by the native pacer). */
328
+ private onFramePresented(seq: number, appId: string, presented: boolean, reason?: string): void {
329
+ this.settlePending(
330
+ (p, s) => s <= seq,
331
+ (p, s) =>
332
+ s === seq
333
+ ? {presented, superseded: false, reason}
334
+ : p.appId === appId
335
+ ? {presented, superseded: true, reason}
336
+ : {presented: false, superseded: true, reason: `superseded by ${appId}`},
337
+ )
338
+ }
339
+
340
+ private settlePending(
341
+ match: (p: {appId: string}, seq: number) => boolean,
342
+ outcome: ScenePresentation | ((p: {appId: string}, seq: number) => ScenePresentation),
343
+ ): void {
344
+ for (const [seq, p] of [...this.pending]) {
345
+ if (!match(p, seq)) continue
346
+ this.pending.delete(seq)
347
+ p.resolve(typeof outcome === "function" ? outcome(p, seq) : outcome)
348
+ }
252
349
  }
253
350
 
254
351
  /**
@@ -271,13 +368,16 @@ class SceneRenderer {
271
368
  */
272
369
  public markViewStale(view: "main" | "dashboard"): void {
273
370
  this.store.markViewStale(view)
371
+ // Whatever was in flight for the view got wiped with it.
372
+ this.settlePending(() => true, {presented: false, superseded: true, reason: "cleared"})
274
373
  }
275
374
 
276
- private sendFrame(frame: SceneFrame): void {
375
+ /** Returns false when the frame stayed phone-side (no presentation report will follow). */
376
+ private sendFrame(frame: SceneFrame): boolean {
277
377
  // Single native view: only the visible surface's frames go out. Suppressed
278
378
  // main frames stay committed; setDashboardActive(false) replays them.
279
379
  const dashboardActive = useDisplayStore.getState().dashboardActive
280
- if (dashboardActive !== (frame.view === "dashboard")) return
380
+ if (dashboardActive !== (frame.view === "dashboard")) return false
281
381
  try {
282
382
  // Frames carry the canvas they were laid out against; only a frame from
283
383
  // a path that predates the tag falls back to the current capabilities.
@@ -302,8 +402,10 @@ class SceneRenderer {
302
402
  elements: frame.elements,
303
403
  },
304
404
  })
405
+ return true
305
406
  } catch (err) {
306
407
  console.error(`${LOG_TAG}: native scene send failed:`, err)
408
+ return false
307
409
  }
308
410
  }
309
411
 
@@ -313,6 +415,10 @@ class SceneRenderer {
313
415
 
314
416
  public _resetForTest(): void {
315
417
  this.store.clearAll()
418
+ this.settlePending(() => true, {presented: false, superseded: false, reason: "reset"})
419
+ this.presentedSub?.remove()
420
+ this.presentedSub = null
421
+ this.seqCounter = 0
316
422
  }
317
423
 
318
424
  public _lastFrameForTest(appId: string, view: string): FrameElement[] {
Binary file
Binary file