Skip to content
Source
Sign inSign up
Open History to see the last commit

deviceStreamSession.ts

146 lines5.4 KB

1import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
2import type { DuoControlState } from "@t3tools/client-runtime/device/duo-control";
3import {
4 createDeviceStreamClient,
5 type DeviceScreenSize,
6 type DeviceStreamClient,
7 type DeviceStreamStatus,
8} from "@t3tools/client-runtime/device/stream";
9import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
10import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts";
11import { Atom } from "effect/reactivity";
12​
13import { refreshDeviceHubAccess } from "~/state/device";
14​
15export interface DeviceStreamState {
16 readonly status: DeviceStreamStatus;
17 readonly detail: string | undefined;
18 /** A frame arrived during this session, so a reconnect can keep showing it. */
19 readonly hasFrame: boolean;
20 readonly screen: DeviceScreenSize | null;
21 readonly mjpegUrl: string | null;
22 readonly mjpegGeneration: number;
23 readonly input: { readonly connected: boolean; readonly detail?: string };
24 readonly duoControl: DuoControlState;
25 readonly phoneUnavailable: boolean;
27​
28export interface DeviceStreamSession {
29 readonly stream: DeviceStreamClient;
30 /** The stream draws here; the view mounts it and the 3D viewers sample it. */
31 readonly canvas: HTMLCanvasElement;
32 readonly setFrameListener: (listener: (() => void) | null) => void;
33 /** Called when the hub rejects a Duo control, so the 3D viewer drops its optimistic pose. */
34 readonly setControlRejectedListener: (listener: (() => void) | null) => void;
35 readonly markPhoneUnavailable: () => void;
36 readonly reconnect: () => void;
37 readonly state: DeviceStreamState;
39​
40export interface DeviceStreamKey {
41 readonly environmentId: EnvironmentId;
42 readonly hostId: string;
43 readonly platform: DevicePlatform;
44 readonly deviceId: string;
45 readonly access: DeviceHubAccess;
46 /** See `DeviceStreamTarget.serveSimOnly`. */
47 readonly serveSimOnly: boolean;
48 /** Each mounted view owns its own client: one canvas cannot be in two places. */
49 readonly viewId: string;
51​
52export const IDLE_DEVICE_STREAM_STATE: DeviceStreamState = {
53 status: "connecting",
54 detail: undefined,
55 hasFrame: false,
56 screen: null,
57 mjpegUrl: null,
58 mjpegGeneration: 0,
59 input: { connected: false },
60 duoControl: { pending: false, requested: null, error: null },
61 phoneUnavailable: false,
62};
63​
64export const deviceScreenKey = (environmentId: EnvironmentId, hostId: string, deviceId: string) =>
65 `${environmentId}\u0000${hostId}\u0000${deviceId}`;
66​
67/**
68 * The screen one view's stream last reported, null while that view streams nothing.
69 * Keyed by view, not device: two views of one device stream independently, and one
70 * ending must not clear the other's size. The mini player lays out its frame from it.
71 */
72export const deviceStreamScreenAtom = Atom.family((viewId: string) =>
73 Atom.make<DeviceScreenSize | null>(null).pipe(Atom.withLabel(`device-stream-screen:${viewId}`)),
74);
75​
76/** A device's video and input connection, live while a view reads it. */
77export const deviceStreamSessionAtom = Atom.family((key: DeviceStreamKey) =>
78 Atom.make((get): DeviceStreamSession => {
79 const screenAtom = deviceStreamScreenAtom(key.viewId);
80 const canvas = document.createElement("canvas");
81 canvas.style.display = "block";
82 canvas.style.width = "100%";
83 canvas.style.height = "100%";
84 let frameListener: (() => void) | null = null;
85 let controlRejectedListener: (() => void) | null = null;
86 let state = IDLE_DEVICE_STREAM_STATE;
87 let session: DeviceStreamSession;
88 const update = (patch: Partial<DeviceStreamState>) => {
89 state = { ...state, ...patch };
90 session = { ...session, state };
91 get.setSelf(session);
92 };
93 const client = createDeviceStreamClient(
94 {
95 platform: key.platform,
96 deviceId: key.deviceId,
97 access: key.access,
98 serveSimOnly: key.serveSimOnly,
99 },
100 createCanvasFrameSink(canvas, () => frameListener?.()),
101 {
102 onDuoControl: (duoControl) => {
103 if (duoControl.error && duoControl.error !== state.duoControl.error)
104 controlRejectedListener?.();
105 update({ duoControl });
106 },
107 onDuoUnavailable: () => update({ phoneUnavailable: true }),
108 onStatus: (status, detail) =>
109 update({ status, detail, ...(status === "streaming" ? { hasFrame: true } : {}) }),
110 onScreen: (screen) => {
111 get.set(screenAtom, screen);
112 update({ screen });
113 },
114 onUnauthorized: () => refreshDeviceHubAccess(key.environmentId),
115 onMjpegFallback: (mjpegUrl) =>
116 update({ mjpegUrl, mjpegGeneration: state.mjpegGeneration + 1 }),
117 onCanvasVideo: () => update({ mjpegUrl: null }),
118 onInputConnected: (connected, detail) =>
119 update({ input: { connected, ...(detail ? { detail } : {}) } }),
120 },
121 );
122 session = {
123 stream: client,
124 canvas,
125 setFrameListener: (listener) => {
126 frameListener = listener;
127 },
128 setControlRejectedListener: (listener) => {
129 controlRejectedListener = listener;
130 },
131 markPhoneUnavailable: () => update({ phoneUnavailable: true }),
132 reconnect: () => {
133 client.stop();
134 update({ phoneUnavailable: false });
135 client.start();
136 },
137 state,
138 };
139 get.addFinalizer(() => {
140 client.stop();
141 get.registry.set(screenAtom, null);
142 });
143 client.start();
144 return session;
145 }).pipe(Atom.withLabel(`device-stream:${key.platform}:${key.deviceId}`)),
146);