@mentra/miniapp 3.2.1-beta.319 → 3.2.1-beta.354

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.
Files changed (102) hide show
  1. package/README.md +16 -0
  2. package/dist/background/index.d.ts +5 -3
  3. package/dist/background/index.d.ts.map +1 -1
  4. package/dist/background/index.js +1 -0
  5. package/dist/background/index.js.map +1 -1
  6. package/dist/index.d.ts +6 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/modules/display.d.ts +33 -0
  11. package/dist/modules/display.d.ts.map +1 -1
  12. package/dist/modules/display.js +2 -0
  13. package/dist/modules/display.js.map +1 -1
  14. package/dist/modules/meeting.d.ts +66 -6
  15. package/dist/modules/meeting.d.ts.map +1 -1
  16. package/dist/modules/meeting.js +72 -19
  17. package/dist/modules/meeting.js.map +1 -1
  18. package/dist/modules/stream.d.ts +46 -0
  19. package/dist/modules/stream.d.ts.map +1 -1
  20. package/dist/modules/stream.js +107 -0
  21. package/dist/modules/stream.js.map +1 -1
  22. package/dist/protocol.d.ts +39 -0
  23. package/dist/protocol.d.ts.map +1 -1
  24. package/dist/protocol.js +36 -0
  25. package/dist/protocol.js.map +1 -1
  26. package/dist/react/StreamPreview.d.ts +41 -0
  27. package/dist/react/StreamPreview.d.ts.map +1 -0
  28. package/dist/react/StreamPreview.js +144 -0
  29. package/dist/react/StreamPreview.js.map +1 -0
  30. package/dist/react/index.d.ts +2 -0
  31. package/dist/react/index.d.ts.map +1 -1
  32. package/dist/react/index.js +1 -0
  33. package/dist/react/index.js.map +1 -1
  34. package/dist/react/stream-preview/PreviewConnection.d.ts +139 -0
  35. package/dist/react/stream-preview/PreviewConnection.d.ts.map +1 -0
  36. package/dist/react/stream-preview/PreviewConnection.js +462 -0
  37. package/dist/react/stream-preview/PreviewConnection.js.map +1 -0
  38. package/dist/react/stream-preview/controlChannel.d.ts +75 -0
  39. package/dist/react/stream-preview/controlChannel.d.ts.map +1 -0
  40. package/dist/react/stream-preview/controlChannel.js +55 -0
  41. package/dist/react/stream-preview/controlChannel.js.map +1 -0
  42. package/dist/react/stream-preview/creditLoop.d.ts +81 -0
  43. package/dist/react/stream-preview/creditLoop.d.ts.map +1 -0
  44. package/dist/react/stream-preview/creditLoop.js +146 -0
  45. package/dist/react/stream-preview/creditLoop.js.map +1 -0
  46. package/dist/react/stream-preview/protocol.d.ts +113 -0
  47. package/dist/react/stream-preview/protocol.d.ts.map +1 -0
  48. package/dist/react/stream-preview/protocol.js +148 -0
  49. package/dist/react/stream-preview/protocol.js.map +1 -0
  50. package/dist/react/stream-preview/resizeCoalescer.d.ts +38 -0
  51. package/dist/react/stream-preview/resizeCoalescer.d.ts.map +1 -0
  52. package/dist/react/stream-preview/resizeCoalescer.js +71 -0
  53. package/dist/react/stream-preview/resizeCoalescer.js.map +1 -0
  54. package/dist/react/stream-preview/testFrames.d.ts +38 -0
  55. package/dist/react/stream-preview/testFrames.d.ts.map +1 -0
  56. package/dist/react/stream-preview/testFrames.js +50 -0
  57. package/dist/react/stream-preview/testFrames.js.map +1 -0
  58. package/dist/react/stream-preview/tiers.d.ts +21 -0
  59. package/dist/react/stream-preview/tiers.d.ts.map +1 -0
  60. package/dist/react/stream-preview/tiers.js +33 -0
  61. package/dist/react/stream-preview/tiers.js.map +1 -0
  62. package/dist/react/stream-preview/trace.d.ts +23 -0
  63. package/dist/react/stream-preview/trace.d.ts.map +1 -0
  64. package/dist/react/stream-preview/trace.js +71 -0
  65. package/dist/react/stream-preview/trace.js.map +1 -0
  66. package/dist/react/stream-preview/transport.d.ts +79 -0
  67. package/dist/react/stream-preview/transport.d.ts.map +1 -0
  68. package/dist/react/stream-preview/transport.js +209 -0
  69. package/dist/react/stream-preview/transport.js.map +1 -0
  70. package/dist/react/stream-preview/yuvRenderer.d.ts +31 -0
  71. package/dist/react/stream-preview/yuvRenderer.d.ts.map +1 -0
  72. package/dist/react/stream-preview/yuvRenderer.js +398 -0
  73. package/dist/react/stream-preview/yuvRenderer.js.map +1 -0
  74. package/dist/session.d.ts +5 -0
  75. package/dist/session.d.ts.map +1 -1
  76. package/dist/session.js +5 -0
  77. package/dist/session.js.map +1 -1
  78. package/dist/ui/index.d.ts +2 -0
  79. package/dist/ui/index.d.ts.map +1 -1
  80. package/dist/ui/index.js +1 -0
  81. package/dist/ui/index.js.map +1 -1
  82. package/package.json +8 -3
  83. package/src/background/index.ts +14 -1
  84. package/src/index.ts +15 -0
  85. package/src/modules/display.ts +37 -0
  86. package/src/modules/meeting.ts +133 -23
  87. package/src/modules/stream.ts +153 -1
  88. package/src/protocol.ts +75 -0
  89. package/src/react/StreamPreview.tsx +187 -0
  90. package/src/react/index.ts +2 -0
  91. package/src/react/stream-preview/PreviewConnection.ts +552 -0
  92. package/src/react/stream-preview/controlChannel.ts +119 -0
  93. package/src/react/stream-preview/creditLoop.ts +189 -0
  94. package/src/react/stream-preview/protocol.ts +227 -0
  95. package/src/react/stream-preview/resizeCoalescer.ts +88 -0
  96. package/src/react/stream-preview/testFrames.ts +86 -0
  97. package/src/react/stream-preview/tiers.ts +37 -0
  98. package/src/react/stream-preview/trace.ts +81 -0
  99. package/src/react/stream-preview/transport.ts +242 -0
  100. package/src/react/stream-preview/yuvRenderer.ts +453 -0
  101. package/src/session.ts +8 -0
  102. package/src/ui/index.ts +2 -0
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * @fileoverview MeetingModule — phone-native meeting (ACS Teams).
3
3
  *
4
- * V1 token pass-through is deliberate technical debt (identity ticket):
5
- * later, join(meetingUrl, whepUrl) and the host fetches the credential from
6
- * Porter. Miniapps must not persist the token.
4
+ * Workspace credentials belong to the host. Consumer token pass-through remains
5
+ * available for older hosts and Call backends. Never persist credentials.
7
6
  */
8
7
 
9
8
  import {MiniappErrorCode, MiniappRequestType} from "../protocol"
@@ -30,7 +29,7 @@ export interface MeetingWhepVideoSource {
30
29
  * hotspot, binds a local WHIP endpoint, and tells the glasses where to publish. The miniapp only
31
30
  * chooses the transport.
32
31
  *
33
- * Android only for now. iOS hosts reject this with `NOT_IMPLEMENTED`.
32
+ * Supported by current Android and iOS hosts.
34
33
  */
35
34
  export interface MeetingSoftApVideoSource {
36
35
  type: "softap"
@@ -102,13 +101,28 @@ export interface MeetingJoinOptions {
102
101
  provider: MeetingProvider
103
102
  meetingUrl: string
104
103
  videoSource: MeetingVideoSource
105
- /** V1-only: Porter-minted ACS guest token. Do not persist. */
106
- token: string
104
+ /** Legacy consumer credential. Omit when getConfiguration().credentialSource is runtime. */
105
+ token?: string
107
106
  displayName?: string
108
107
  video?: MeetingOutgoingVideo
109
108
  origin?: MeetingOrigin
110
109
  }
111
110
 
111
+ export interface MeetingCreateOptions {
112
+ provider: MeetingProvider
113
+ subject?: string
114
+ durationMinutes?: number
115
+ }
116
+
117
+ export interface CreatedMeeting {
118
+ provider: MeetingProvider
119
+ joinUrl: string
120
+ /** Opaque ownership receipt. Keep it to retire this meeting later. */
121
+ meetingRef: string
122
+ identityMode: MeetingIdentityMode
123
+ guestReason?: "no-entra-identity" | "teams-license-unavailable"
124
+ }
125
+
112
126
  export type MeetingParticipantState = "idle" | "connecting" | "connected" | "lobby" | "hold" | "disconnected"
113
127
 
114
128
  /** A remote participant as reported by the phone-native meeting client. */
@@ -121,9 +135,43 @@ export interface MeetingParticipant {
121
135
  isSpeaking: boolean
122
136
  }
123
137
 
138
+ export type MeetingIdentityMode = "guest" | "teams-user"
139
+ export type MeetingGuestReason = "no-entra-identity" | "teams-license-unavailable" | "legacy-credential"
140
+
141
+ /**
142
+ * Identity resolved before joining. `teams-user` means Microsoft accepted the
143
+ * Teams calling license, not that the account owns a particular M365 SKU or
144
+ * has permission to organize every meeting. Credentials never cross this API.
145
+ * Acquisition/consent/provider errors reject instead of reporting guest mode.
146
+ */
147
+ export interface MeetingIdentity {
148
+ identityMode: MeetingIdentityMode
149
+ guestReason?: MeetingGuestReason
150
+ /** Present when the host has a signed-in Entra account, including unlicensed accounts. */
151
+ account?: {displayName?: string; email?: string}
152
+ }
153
+
154
+ /** Host policy; this contains no credentials or deployment endpoints. */
155
+ export interface MeetingConfiguration {
156
+ enabled: boolean
157
+ credentialSource: "runtime" | "miniapp"
158
+ externalBackendAllowed: boolean
159
+ managedStreams: boolean
160
+ /** Omitted by older hosts. Runtime creation requires Graph organizer configuration. */
161
+ creationSource?: "runtime" | "miniapp"
162
+ }
163
+
124
164
  export interface MeetingState {
165
+ /** Omitted by older hosts. Tokens never cross this boundary. */
166
+ identityMode?: MeetingIdentityMode
167
+ guestReason?: MeetingGuestReason
125
168
  state: MeetingPhase
126
169
  muted: boolean
170
+ /**
171
+ * Whether remote participants receive the glasses camera. False after `setVideoEnabled(false)`;
172
+ * every new join starts true. Omitted by hosts that cannot toggle outgoing video.
173
+ */
174
+ videoEnabled?: boolean
127
175
  error?: string
128
176
  /** Provider termination details, including Teams' invalid meeting-link codes. */
129
177
  endReason?: MeetingEndReason
@@ -212,12 +260,16 @@ export function parseMeetingCapabilities(raw: unknown): MeetingCapabilities | un
212
260
  if (!value || typeof value !== "object") return undefined
213
261
  const capability = value as Record<string, unknown>
214
262
  const rawLobby = (raw as Record<string, unknown>).manageLobby
215
- const lobby = rawLobby && typeof rawLobby === "object" ? rawLobby as Record<string, unknown> : undefined
263
+ const lobby = rawLobby && typeof rawLobby === "object" ? (rawLobby as Record<string, unknown>) : undefined
216
264
  return {
217
- ...(lobby ? {manageLobby: {
218
- allowed: typeof lobby.allowed === "boolean" ? lobby.allowed : null,
219
- reason: typeof lobby.reason === "string" && lobby.reason ? lobby.reason : null,
220
- }} : {}),
265
+ ...(lobby
266
+ ? {
267
+ manageLobby: {
268
+ allowed: typeof lobby.allowed === "boolean" ? lobby.allowed : null,
269
+ reason: typeof lobby.reason === "string" && lobby.reason ? lobby.reason : null,
270
+ },
271
+ }
272
+ : {}),
221
273
  hangUpForEveryone: {
222
274
  allowed: typeof capability.allowed === "boolean" ? capability.allowed : null,
223
275
  reason: typeof capability.reason === "string" && capability.reason ? capability.reason : null,
@@ -264,14 +316,7 @@ export interface MeetingSoftApRecovery {
264
316
 
265
317
  const SOFTAP_STEPS: ReadonlySet<string> = new Set(["hotspot", "scopedJoin", "acsJoin", "publish", "live"])
266
318
  const SOFTAP_STEP_STATUSES: ReadonlySet<string> = new Set(["pending", "running", "done", "failed"])
267
- const SOFTAP_PHASES: ReadonlySet<string> = new Set([
268
- "idle",
269
- "starting",
270
- "recovering",
271
- "live",
272
- "stopping",
273
- "failed",
274
- ])
319
+ const SOFTAP_PHASES: ReadonlySet<string> = new Set(["idle", "starting", "recovering", "live", "stopping", "failed"])
275
320
 
276
321
  /** Tolerant parse of a host `softap` payload. Unknown steps are dropped; a malformed payload reads as absent. */
277
322
  export function parseMeetingSoftApProgress(raw: unknown): MeetingSoftApProgress | undefined {
@@ -386,9 +431,7 @@ export class MeetingModule {
386
431
  throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "meetingUrl is required"}
387
432
  }
388
433
  const videoSource = validateMeetingVideoSource(options.videoSource)
389
- if (!options.token?.trim()) {
390
- throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "token is required"}
391
- }
434
+
392
435
  try {
393
436
  const result = await this.session.sendRequest<MeetingState | null>(
394
437
  {
@@ -396,7 +439,7 @@ export class MeetingModule {
396
439
  provider: options.provider,
397
440
  meetingUrl: options.meetingUrl,
398
441
  videoSource,
399
- token: options.token,
442
+ ...(options.token ? {token: options.token} : {}),
400
443
  displayName: options.displayName,
401
444
  ...(options.origin ? {origin: options.origin} : {}),
402
445
  ...(options.video ? {video: options.video} : {}),
@@ -410,6 +453,52 @@ export class MeetingModule {
410
453
  }
411
454
  }
412
455
 
456
+ /** Query before using a separate Call backend. Older hosts return NOT_IMPLEMENTED. */
457
+ async getConfiguration(): Promise<MeetingConfiguration> {
458
+ return this.session.sendRequest<MeetingConfiguration>({type: MiniappRequestType.MEETING_GET_CONFIGURATION})
459
+ }
460
+
461
+ /** Resolve the current calling identity without creating or joining a meeting. */
462
+ async getIdentity(): Promise<MeetingIdentity> {
463
+ try {
464
+ return await this.session.sendRequest<MeetingIdentity>(
465
+ {type: MiniappRequestType.MEETING_GET_IDENTITY},
466
+ {timeoutMs: 30_000},
467
+ )
468
+ } catch (error) {
469
+ mapHostError(error)
470
+ }
471
+ }
472
+
473
+ /** Create a Teams meeting in the selected Runtime without exposing provider credentials. */
474
+ async create(options: MeetingCreateOptions): Promise<CreatedMeeting> {
475
+ if (options.provider !== "acs-teams") {
476
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "Unsupported meeting provider"}
477
+ }
478
+ try {
479
+ return await this.session.sendRequest<CreatedMeeting>(
480
+ {
481
+ type: MiniappRequestType.MEETING_CREATE,
482
+ provider: options.provider,
483
+ subject: options.subject,
484
+ durationMinutes: options.durationMinutes,
485
+ },
486
+ {timeoutMs: 0},
487
+ )
488
+ } catch (error) {
489
+ mapHostError(error)
490
+ }
491
+ }
492
+
493
+ /** Delete a meeting created by this caller. Does not hang up an active call. */
494
+ async retire(meetingRef: string): Promise<void> {
495
+ try {
496
+ await this.session.sendRequest<void>({type: MiniappRequestType.MEETING_RETIRE, meetingRef}, {timeoutMs: 0})
497
+ } catch (error) {
498
+ mapHostError(error)
499
+ }
500
+ }
501
+
413
502
  async leave(): Promise<void> {
414
503
  try {
415
504
  await this.session.sendRequest<void>({type: MiniappRequestType.MEETING_LEAVE})
@@ -459,6 +548,23 @@ export class MeetingModule {
459
548
  }
460
549
  }
461
550
 
551
+ /**
552
+ * Stop or resume the glasses camera the meeting receives. The call and the glasses stream stay
553
+ * up, so a local `<StreamPreview>` keeps drawing while remote participants see the camera off.
554
+ * Check `state.videoEnabled !== undefined` before offering it; older hosts reject.
555
+ */
556
+ async setVideoEnabled(enabled: boolean): Promise<void> {
557
+ try {
558
+ const result = await this.session.sendRequest<MeetingState | null>({
559
+ type: MiniappRequestType.MEETING_SET_VIDEO_ENABLED,
560
+ enabled,
561
+ })
562
+ if (result) this._applyState(result)
563
+ } catch (error) {
564
+ mapHostError(error)
565
+ }
566
+ }
567
+
462
568
  /**
463
569
  * Repoint the host at a new WHEP URL mid-call, the recovery path for a re-published stream.
464
570
  *
@@ -502,8 +608,12 @@ export class MeetingModule {
502
608
  /** @internal — applied by MiniappSession on inbound MEETING_STATE. */
503
609
  _applyState(event: MeetingState): void {
504
610
  this._state = {
611
+ identityMode:
612
+ event.identityMode === "guest" || event.identityMode === "teams-user" ? event.identityMode : undefined,
613
+ guestReason: event.identityMode === "guest" ? event.guestReason : undefined,
505
614
  state: event.state,
506
615
  muted: Boolean(event.muted),
616
+ videoEnabled: typeof event.videoEnabled === "boolean" ? event.videoEnabled : undefined,
507
617
  error: event.error,
508
618
  endReason: parseMeetingEndReason(event.endReason),
509
619
  meetingUrl: event.meetingUrl,
@@ -8,7 +8,7 @@
8
8
  * to your own ingest URL with no relay.
9
9
  */
10
10
 
11
- import {MiniappRequestType} from "../protocol"
11
+ import {MiniappRequestType, type PreviewErrorCode, type PreviewSource} from "../protocol"
12
12
  import {MiniappSession} from "../session"
13
13
 
14
14
  export interface StreamVideoConfig {
@@ -133,9 +133,161 @@ export interface StreamStatus {
133
133
  stats?: StreamLiveStats
134
134
  }
135
135
 
136
+ export interface StreamPreviewOptions {
137
+ /** Defaults to `"call"`. `"glasses"` is reserved and rejects with `unsupported`. */
138
+ source?: PreviewSource
139
+ }
140
+
141
+ export type PreviewHandleState = "held" | "ended"
142
+
143
+ export interface PreviewHandleStatus {
144
+ state: PreviewHandleState
145
+ /** Set when `state` is `"ended"`: `stopped`, `source_ended`, `runtime_stopped`, ... */
146
+ reason?: string
147
+ }
148
+
149
+ /** A preview request or lease failed. `code` is one of {@link PreviewErrorCode} when the host sent one. */
150
+ export class PreviewError extends Error {
151
+ readonly code: PreviewErrorCode | string
152
+
153
+ constructor(code: PreviewErrorCode | string, message?: string) {
154
+ super(message ?? code)
155
+ this.name = "PreviewError"
156
+ this.code = code
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The background-owned lease on a preview source.
162
+ *
163
+ * The lease is what lets a `<StreamPreview>` in the UI show frames. It survives the UI closing and
164
+ * the WebView being destroyed, and ends on `stop()`, when the source ends (the meeting is over), or
165
+ * when the background runtime stops. Hiding the component does not release it.
166
+ */
167
+ export interface PreviewHandle {
168
+ readonly source: PreviewSource
169
+ readonly handleId: string
170
+ /** Correlation id stamped on every `PREVIEW_TRACE` log line for this lease. */
171
+ readonly previewTraceId: string
172
+ readonly state: PreviewHandleState
173
+ /** Release the lease. Idempotent. */
174
+ stop(): Promise<void>
175
+ /** Lease transitions. Fires `{state: "ended", reason}` exactly once. */
176
+ onStatus(cb: (status: PreviewHandleStatus) => void): () => void
177
+ }
178
+
179
+ interface PreviewStartResult {
180
+ handleId: string
181
+ previewTraceId: string
182
+ source: PreviewSource
183
+ }
184
+
185
+ function toPreviewError(error: unknown): PreviewError {
186
+ if (error instanceof PreviewError) return error
187
+ const value = (error ?? {}) as {code?: unknown; message?: unknown}
188
+ const code = typeof value.code === "string" ? value.code : "unsupported"
189
+ const message = typeof value.message === "string" ? value.message : undefined
190
+ return new PreviewError(code, message)
191
+ }
192
+
193
+ class PreviewHandleImpl implements PreviewHandle {
194
+ state: PreviewHandleState = "held"
195
+ private readonly listeners = new Set<(status: PreviewHandleStatus) => void>()
196
+
197
+ constructor(
198
+ readonly source: PreviewSource,
199
+ readonly handleId: string,
200
+ readonly previewTraceId: string,
201
+ private readonly release: (handle: PreviewHandleImpl) => Promise<void>,
202
+ ) {}
203
+
204
+ async stop(): Promise<void> {
205
+ if (this.state === "ended") return
206
+ this.end("stopped")
207
+ await this.release(this)
208
+ }
209
+
210
+ onStatus(cb: (status: PreviewHandleStatus) => void): () => void {
211
+ this.listeners.add(cb)
212
+ return () => this.listeners.delete(cb)
213
+ }
214
+
215
+ /** @internal */
216
+ end(reason: string): void {
217
+ if (this.state === "ended") return
218
+ this.state = "ended"
219
+ this.emit({state: "ended", reason})
220
+ }
221
+
222
+ private emit(status: PreviewHandleStatus): void {
223
+ for (const listener of [...this.listeners]) {
224
+ try {
225
+ listener(status)
226
+ } catch {
227
+ // A throwing listener must not stop the others from hearing that the lease ended.
228
+ }
229
+ }
230
+ }
231
+ }
232
+
136
233
  export class StreamModule {
234
+ private readonly previewHandles = new Map<string, PreviewHandleImpl>()
235
+
137
236
  constructor(private readonly session: MiniappSession) {}
138
237
 
238
+ /**
239
+ * Take the preview lease on a source so a `<StreamPreview>` in the UI can show its frames.
240
+ *
241
+ * For `"call"` the caller must declare `CAMERA` and own the active meeting (`session.meeting`).
242
+ * Only one lease exists at a time; a second request fails with `preview_busy`. Rejects with a
243
+ * {@link PreviewError}. Never await this on a call's connect path: preview is additive.
244
+ */
245
+ async preview(options: StreamPreviewOptions = {}): Promise<PreviewHandle> {
246
+ const source = options.source ?? "call"
247
+ if (source !== "call") {
248
+ throw new PreviewError("unsupported", `Preview source "${String(source)}" is not supported yet`)
249
+ }
250
+ let result: PreviewStartResult
251
+ try {
252
+ result = await this.session.sendRequest<PreviewStartResult>({
253
+ type: MiniappRequestType.STREAM_PREVIEW_START,
254
+ source,
255
+ })
256
+ } catch (error) {
257
+ throw toPreviewError(error)
258
+ }
259
+ const handle = new PreviewHandleImpl(result.source ?? source, result.handleId, result.previewTraceId, (h) =>
260
+ this.releasePreview(h),
261
+ )
262
+ this.previewHandles.set(handle.handleId, handle)
263
+ handle.onStatus((status) => {
264
+ if (status.state === "ended") this.previewHandles.delete(handle.handleId)
265
+ })
266
+ return handle
267
+ }
268
+
269
+ private async releasePreview(handle: PreviewHandleImpl): Promise<void> {
270
+ this.previewHandles.delete(handle.handleId)
271
+ try {
272
+ await this.session.sendRequest<void>({
273
+ type: MiniappRequestType.STREAM_PREVIEW_STOP,
274
+ handleId: handle.handleId,
275
+ })
276
+ } catch (error) {
277
+ throw toPreviewError(error)
278
+ }
279
+ }
280
+
281
+ /** @internal — applied by MiniappSession on inbound STREAM_PREVIEW_STATUS. */
282
+ _applyPreviewStatus(payload: Record<string, unknown>): void {
283
+ if (typeof payload.handleId !== "string") return
284
+ const handle = this.previewHandles.get(payload.handleId)
285
+ if (!handle) return
286
+ if (payload.state === "ended") {
287
+ handle.end(typeof payload.reason === "string" ? payload.reason : "ended")
288
+ }
289
+ }
290
+
139
291
  /**
140
292
  * Start a live video stream from the glasses camera.
141
293
  *
package/src/protocol.ts CHANGED
@@ -196,6 +196,13 @@ export enum MiniappRequestType {
196
196
  STREAM_STOP = "miniapp_stream_stop",
197
197
  MANAGED_STREAM_START = "miniapp_managed_stream_start",
198
198
  MANAGED_STREAM_STOP = "miniapp_managed_stream_stop",
199
+ /**
200
+ * Take the single raw decoded-frame preview lease on a source. See
201
+ * `session.stream.preview()`. Replies `{handleId, previewTraceId, source}`.
202
+ */
203
+ STREAM_PREVIEW_START = "miniapp_stream_preview_start",
204
+ /** Release a preview lease by `handleId`. */
205
+ STREAM_PREVIEW_STOP = "miniapp_stream_preview_stop",
199
206
 
200
207
  /** Ask the host to open the glasses Wi-Fi setup flow (mirrors the cloud SDK's requestWifiSetup). */
201
208
  REQUEST_WIFI_SETUP = "miniapp_request_wifi_setup",
@@ -220,6 +227,10 @@ export enum MiniappRequestType {
220
227
  * Phone-native meeting (ACS Teams). Join/leave/mute live in the MentraOS
221
228
  * host so the miniapp never holds the ACS Calling SDK.
222
229
  */
230
+ MEETING_GET_IDENTITY = "miniapp_meeting_get_identity",
231
+ MEETING_GET_CONFIGURATION = "miniapp_meeting_get_configuration",
232
+ MEETING_CREATE = "miniapp_meeting_create",
233
+ MEETING_RETIRE = "miniapp_meeting_retire",
223
234
  MEETING_JOIN = "miniapp_meeting_join",
224
235
  MEETING_LEAVE = "miniapp_meeting_leave",
225
236
  /** Terminate the meeting for everyone, not just this device. See `meeting.end()`. */
@@ -227,6 +238,8 @@ export enum MiniappRequestType {
227
238
  /** Admit one waiting participant, when the host has Teams lobby permission. */
228
239
  MEETING_ADMIT = "miniapp_meeting_admit",
229
240
  MEETING_SET_MUTED = "miniapp_meeting_set_muted",
241
+ /** Stop or resume the glasses camera the meeting receives, without leaving. */
242
+ MEETING_SET_VIDEO_ENABLED = "miniapp_meeting_set_video_enabled",
230
243
  MEETING_UPDATE_VIDEO_SOURCE = "miniapp_meeting_update_video_source",
231
244
  MEETING_GET_STATE = "miniapp_meeting_get_state",
232
245
  }
@@ -291,6 +304,12 @@ export enum MiniappResponseType {
291
304
  */
292
305
  MEETING_STATE = "miniapp_meeting_state",
293
306
 
307
+ /**
308
+ * Push: a preview lease changed state. Carries {handleId, state: "held" | "ended", reason?}.
309
+ * See PreviewHandle.onStatus().
310
+ */
311
+ STREAM_PREVIEW_STATUS = "miniapp_stream_preview_status",
312
+
294
313
  /**
295
314
  * Push: phone is about to tear down the miniapp's session. Gives the SDK
296
315
  * a brief window (~50ms grace on the phone side) to fire one last
@@ -431,3 +450,59 @@ export enum MiniappErrorCode {
431
450
  /** `blob.importFile` failed (not the user cancelling — that resolves to null). */
432
451
  BLOB_IMPORT_FAILED = "BLOB_IMPORT_FAILED",
433
452
  }
453
+
454
+ // ============================================================================
455
+ // Stream preview
456
+ // ============================================================================
457
+
458
+ /**
459
+ * Where a raw preview comes from. `"call"` is the decoded video of the phone-native meeting the
460
+ * caller owns. `"glasses"` is reserved and currently rejects with `unsupported`.
461
+ */
462
+ export type PreviewSource = "call" | "glasses"
463
+
464
+ /**
465
+ * Typed preview failures and non-terminal statuses, shared by the background handle, the UI
466
+ * component and the host. `waiting_for_lease` and `paused_background` are statuses, not errors.
467
+ */
468
+ export type PreviewErrorCode =
469
+ | "permission_denied"
470
+ | "not_meeting_owner"
471
+ | "preview_busy"
472
+ | "waiting_for_lease"
473
+ | "source_ended"
474
+ | "unsupported"
475
+ | "ack_timeout"
476
+ | "transport_failed"
477
+ | "pack_failed"
478
+ | "paused_background"
479
+ | "diagnostics_disabled"
480
+
481
+ export const PREVIEW_ERROR_CODES: readonly PreviewErrorCode[] = [
482
+ "permission_denied",
483
+ "not_meeting_owner",
484
+ "preview_busy",
485
+ "waiting_for_lease",
486
+ "source_ended",
487
+ "unsupported",
488
+ "ack_timeout",
489
+ "transport_failed",
490
+ "pack_failed",
491
+ "paused_background",
492
+ "diagnostics_disabled",
493
+ ]
494
+
495
+ export function isPreviewErrorCode(value: unknown): value is PreviewErrorCode {
496
+ return typeof value === "string" && (PREVIEW_ERROR_CODES as readonly string[]).includes(value)
497
+ }
498
+
499
+ /** Lease state pushed to the background with `STREAM_PREVIEW_STATUS`. */
500
+ export interface PreviewStatus {
501
+ handleId: string
502
+ state: "held" | "ended"
503
+ /** Why the lease ended: `stopped`, `source_ended`, `runtime_stopped`, ... */
504
+ reason?: string
505
+ }
506
+
507
+ /** The single reserved UI channel the host answers itself for preview control. */
508
+ export const PREVIEW_UI_CHANNEL = "_preview"