@mentra/miniapp 3.2.1-beta.319 → 3.2.1-beta.355
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/README.md +16 -0
- package/dist/background/index.d.ts +5 -3
- package/dist/background/index.d.ts.map +1 -1
- package/dist/background/index.js +1 -0
- package/dist/background/index.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/display.d.ts +33 -0
- package/dist/modules/display.d.ts.map +1 -1
- package/dist/modules/display.js +2 -0
- package/dist/modules/display.js.map +1 -1
- package/dist/modules/meeting.d.ts +66 -6
- package/dist/modules/meeting.d.ts.map +1 -1
- package/dist/modules/meeting.js +72 -19
- package/dist/modules/meeting.js.map +1 -1
- package/dist/modules/stream.d.ts +46 -0
- package/dist/modules/stream.d.ts.map +1 -1
- package/dist/modules/stream.js +107 -0
- package/dist/modules/stream.js.map +1 -1
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +36 -0
- package/dist/protocol.js.map +1 -1
- package/dist/react/StreamPreview.d.ts +41 -0
- package/dist/react/StreamPreview.d.ts.map +1 -0
- package/dist/react/StreamPreview.js +144 -0
- package/dist/react/StreamPreview.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/stream-preview/PreviewConnection.d.ts +139 -0
- package/dist/react/stream-preview/PreviewConnection.d.ts.map +1 -0
- package/dist/react/stream-preview/PreviewConnection.js +462 -0
- package/dist/react/stream-preview/PreviewConnection.js.map +1 -0
- package/dist/react/stream-preview/controlChannel.d.ts +75 -0
- package/dist/react/stream-preview/controlChannel.d.ts.map +1 -0
- package/dist/react/stream-preview/controlChannel.js +55 -0
- package/dist/react/stream-preview/controlChannel.js.map +1 -0
- package/dist/react/stream-preview/creditLoop.d.ts +81 -0
- package/dist/react/stream-preview/creditLoop.d.ts.map +1 -0
- package/dist/react/stream-preview/creditLoop.js +146 -0
- package/dist/react/stream-preview/creditLoop.js.map +1 -0
- package/dist/react/stream-preview/protocol.d.ts +113 -0
- package/dist/react/stream-preview/protocol.d.ts.map +1 -0
- package/dist/react/stream-preview/protocol.js +148 -0
- package/dist/react/stream-preview/protocol.js.map +1 -0
- package/dist/react/stream-preview/resizeCoalescer.d.ts +38 -0
- package/dist/react/stream-preview/resizeCoalescer.d.ts.map +1 -0
- package/dist/react/stream-preview/resizeCoalescer.js +71 -0
- package/dist/react/stream-preview/resizeCoalescer.js.map +1 -0
- package/dist/react/stream-preview/testFrames.d.ts +38 -0
- package/dist/react/stream-preview/testFrames.d.ts.map +1 -0
- package/dist/react/stream-preview/testFrames.js +50 -0
- package/dist/react/stream-preview/testFrames.js.map +1 -0
- package/dist/react/stream-preview/tiers.d.ts +21 -0
- package/dist/react/stream-preview/tiers.d.ts.map +1 -0
- package/dist/react/stream-preview/tiers.js +33 -0
- package/dist/react/stream-preview/tiers.js.map +1 -0
- package/dist/react/stream-preview/trace.d.ts +23 -0
- package/dist/react/stream-preview/trace.d.ts.map +1 -0
- package/dist/react/stream-preview/trace.js +71 -0
- package/dist/react/stream-preview/trace.js.map +1 -0
- package/dist/react/stream-preview/transport.d.ts +79 -0
- package/dist/react/stream-preview/transport.d.ts.map +1 -0
- package/dist/react/stream-preview/transport.js +209 -0
- package/dist/react/stream-preview/transport.js.map +1 -0
- package/dist/react/stream-preview/yuvRenderer.d.ts +31 -0
- package/dist/react/stream-preview/yuvRenderer.d.ts.map +1 -0
- package/dist/react/stream-preview/yuvRenderer.js +398 -0
- package/dist/react/stream-preview/yuvRenderer.js.map +1 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +5 -0
- package/dist/session.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/package.json +8 -3
- package/src/background/index.ts +14 -1
- package/src/index.ts +15 -0
- package/src/modules/display.ts +37 -0
- package/src/modules/meeting.ts +133 -23
- package/src/modules/stream.ts +153 -1
- package/src/protocol.ts +75 -0
- package/src/react/StreamPreview.tsx +187 -0
- package/src/react/index.ts +2 -0
- package/src/react/stream-preview/PreviewConnection.ts +552 -0
- package/src/react/stream-preview/controlChannel.ts +119 -0
- package/src/react/stream-preview/creditLoop.ts +189 -0
- package/src/react/stream-preview/protocol.ts +227 -0
- package/src/react/stream-preview/resizeCoalescer.ts +88 -0
- package/src/react/stream-preview/testFrames.ts +86 -0
- package/src/react/stream-preview/tiers.ts +37 -0
- package/src/react/stream-preview/trace.ts +81 -0
- package/src/react/stream-preview/transport.ts +242 -0
- package/src/react/stream-preview/yuvRenderer.ts +453 -0
- package/src/session.ts +8 -0
- package/src/ui/index.ts +2 -0
package/src/modules/meeting.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview MeetingModule — phone-native meeting (ACS Teams).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
106
|
-
token
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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,
|
package/src/modules/stream.ts
CHANGED
|
@@ -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"
|