@interlucent/pixel-stream-react 0.0.2 → 0.0.4
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/dist/index.d.ts +10 -9
- package/dist/index.js +161 -119
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* SSR-safe: renders an empty <pixel-stream> tag on the server, hydrates
|
|
5
5
|
* on the client by lazily importing the web component definition.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
7
|
+
* Follows the @lit/react `createComponent` pattern:
|
|
8
|
+
* - Properties set directly on the element (not via setAttribute)
|
|
9
|
+
* - useLayoutEffect for synchronous prop sync (before paint)
|
|
10
|
+
* - Stable EventListener-interface objects (one addEventListener per event, ever)
|
|
11
|
+
* - Previous-props tracking for clean removal
|
|
11
12
|
*/
|
|
12
13
|
import React, { type RefObject } from 'react';
|
|
13
14
|
import type { PixelStream as PixelStreamElement, PixelStreamStatus, ResolutionClampName, StreamResolutionClamp } from '@interlucent/pixel-stream';
|
|
@@ -141,10 +142,11 @@ export interface PixelStreamProps extends PixelStreamAttributeProps, PixelStream
|
|
|
141
142
|
/**
|
|
142
143
|
* React component wrapping the `<pixel-stream>` web component.
|
|
143
144
|
*
|
|
144
|
-
*
|
|
145
|
-
* -
|
|
146
|
-
* -
|
|
147
|
-
* -
|
|
145
|
+
* Uses the same patterns as `@lit/react` / Stencil output targets:
|
|
146
|
+
* - Properties set directly on the element (preserves types)
|
|
147
|
+
* - `useLayoutEffect` for synchronous prop sync (no flash)
|
|
148
|
+
* - Stable `EventListener` interface objects (one `addEventListener` per event, ever)
|
|
149
|
+
* - Previous-props `Map` tracking for efficient diffing
|
|
148
150
|
*
|
|
149
151
|
* @example
|
|
150
152
|
* ```tsx
|
|
@@ -158,7 +160,6 @@ export interface PixelStreamProps extends PixelStreamAttributeProps, PixelStream
|
|
|
158
160
|
* ref={ref}
|
|
159
161
|
* admissionToken="tok_..."
|
|
160
162
|
* onStatusChange={(e) => console.log(e.detail.newStatus)}
|
|
161
|
-
* onReady={() => console.log('ready')}
|
|
162
163
|
* style={{ width: '100%', height: '100%' }}
|
|
163
164
|
* />
|
|
164
165
|
* );
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
/**
|
|
2
3
|
* React bindings for the <pixel-stream> web component.
|
|
3
4
|
*
|
|
4
5
|
* SSR-safe: renders an empty <pixel-stream> tag on the server, hydrates
|
|
5
6
|
* on the client by lazily importing the web component definition.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
8
|
+
* Follows the @lit/react `createComponent` pattern:
|
|
9
|
+
* - Properties set directly on the element (not via setAttribute)
|
|
10
|
+
* - useLayoutEffect for synchronous prop sync (before paint)
|
|
11
|
+
* - Stable EventListener-interface objects (one addEventListener per event, ever)
|
|
12
|
+
* - Previous-props tracking for clean removal
|
|
11
13
|
*/
|
|
12
|
-
import React, { useRef, useEffect, useState,
|
|
14
|
+
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, forwardRef, } from 'react';
|
|
13
15
|
// ===== SSR guard =====
|
|
14
16
|
const isBrowser = typeof window !== 'undefined';
|
|
17
|
+
// useLayoutEffect warns in SSR — swap to useEffect on the server (which is a no-op anyway)
|
|
18
|
+
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
|
|
15
19
|
// Lazy web-component registration — called once on first mount
|
|
16
20
|
let _registered = false;
|
|
17
21
|
function ensureRegistered() {
|
|
@@ -58,61 +62,66 @@ const EVENT_MAP = {
|
|
|
58
62
|
onXRStreamDetected: 'xr-stream-detected',
|
|
59
63
|
onLog: 'interlucent:log',
|
|
60
64
|
};
|
|
61
|
-
// ===== Mapping from React prop to
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
// ===== Mapping from React camelCase prop to element property name =====
|
|
66
|
+
// These are set directly on the element via `el[prop] = value` (not setAttribute).
|
|
67
|
+
const PROP_MAP = {
|
|
68
|
+
admissionToken: 'admissionToken',
|
|
69
|
+
appId: 'appId',
|
|
70
|
+
appVersion: 'appVersion',
|
|
71
|
+
noAutoConnect: 'noAutoConnect',
|
|
72
|
+
nativeTouch: 'nativeTouch',
|
|
73
|
+
pointerLock: 'pointerLock',
|
|
74
|
+
pointerLockRelease: 'pointerLockRelease',
|
|
75
|
+
suppressBrowserKeys: 'suppressBrowserKeys',
|
|
76
|
+
mute: 'mute',
|
|
77
|
+
volume: 'volume',
|
|
78
|
+
rendezvousPreference: 'rendezvousPreference',
|
|
79
|
+
lingerPreference: 'lingerPreference',
|
|
80
|
+
leftGracePeriod: 'leftGracePeriod',
|
|
81
|
+
apiEndpoint: 'apiEndpoint',
|
|
82
|
+
reconnectMode: 'reconnectMode',
|
|
83
|
+
reconnectAttempts: 'reconnectAttempts',
|
|
84
|
+
reconnectStrategy: 'reconnectStrategy',
|
|
85
|
+
reconnectInterval: 'reconnectInterval',
|
|
86
|
+
disconnectGraceMs: 'disconnectGraceMs',
|
|
87
|
+
resizeMode: 'resizeMode',
|
|
88
|
+
dprCap: 'dprCap',
|
|
89
|
+
resolutionClamp: 'resolutionClamp',
|
|
90
|
+
debug: 'debug',
|
|
91
|
+
logLevel: 'logLevel',
|
|
92
|
+
iceBatchDelay: 'iceBatchDelay',
|
|
93
|
+
};
|
|
94
|
+
// Props that must go through setAttribute (no matching JS property on the element)
|
|
95
|
+
const ATTR_ONLY_MAP = {
|
|
71
96
|
enableGamepad: 'enable-gamepad',
|
|
72
97
|
enableXr: 'enable-xr',
|
|
73
98
|
forceRelay: 'force-relay',
|
|
74
99
|
queueWaitTolerance: 'queue-wait-tolerance',
|
|
75
100
|
webrtcNegotiationTolerance: 'webrtc-negotiation-tolerance',
|
|
76
|
-
|
|
77
|
-
reconnectAttempts: 'reconnect-attempts',
|
|
78
|
-
reconnectStrategy: 'reconnect-strategy',
|
|
79
|
-
reconnectInterval: 'reconnect-interval',
|
|
80
|
-
disconnectGraceMs: 'disconnect-grace-ms',
|
|
81
|
-
resizeMode: 'resize-mode',
|
|
82
|
-
dprCap: 'dpr-cap',
|
|
83
|
-
resolutionClamp: 'resolution-clamp',
|
|
101
|
+
controls: 'controls',
|
|
84
102
|
swiftJobRequest: 'swift-job-request',
|
|
85
103
|
streamerId: 'streamer-id',
|
|
104
|
+
lat: 'lat',
|
|
105
|
+
lng: 'lng',
|
|
86
106
|
streamQuality: 'stream-quality',
|
|
87
107
|
videoBitrateMin: 'video-bitrate-min',
|
|
88
108
|
videoBitrateStart: 'video-bitrate-start',
|
|
89
109
|
videoBitrateMax: 'video-bitrate-max',
|
|
90
|
-
logLevel: 'log-level',
|
|
91
|
-
rendezvousPreference: 'rendezvous-preference',
|
|
92
|
-
lingerPreference: 'linger-preference',
|
|
93
|
-
leftGracePeriod: 'left-grace-period',
|
|
94
|
-
apiEndpoint: 'api-endpoint',
|
|
95
110
|
};
|
|
96
|
-
//
|
|
111
|
+
// Boolean attributes (for ATTR_ONLY props — presence = true, absence = false)
|
|
97
112
|
const BOOLEAN_ATTRS = new Set([
|
|
98
|
-
'
|
|
99
|
-
'suppressBrowserKeys', 'enableGamepad', 'enableXr', 'mute', 'debug',
|
|
100
|
-
'controls', 'forceRelay', 'swiftJobRequest',
|
|
101
|
-
]);
|
|
102
|
-
// All known attribute prop names (for removal tracking)
|
|
103
|
-
const ALL_ATTR_NAMES = new Set([
|
|
104
|
-
...Object.keys(ATTR_MAP),
|
|
105
|
-
'mute', 'volume', 'debug', 'controls', 'lat', 'lng',
|
|
113
|
+
'enableGamepad', 'enableXr', 'forceRelay', 'controls', 'swiftJobRequest',
|
|
106
114
|
]);
|
|
107
|
-
// Props handled
|
|
108
|
-
const
|
|
115
|
+
// Props handled by React directly
|
|
116
|
+
const REACT_PROPS = new Set(['className', 'style', 'children']);
|
|
109
117
|
/**
|
|
110
118
|
* React component wrapping the `<pixel-stream>` web component.
|
|
111
119
|
*
|
|
112
|
-
*
|
|
113
|
-
* -
|
|
114
|
-
* -
|
|
115
|
-
* -
|
|
120
|
+
* Uses the same patterns as `@lit/react` / Stencil output targets:
|
|
121
|
+
* - Properties set directly on the element (preserves types)
|
|
122
|
+
* - `useLayoutEffect` for synchronous prop sync (no flash)
|
|
123
|
+
* - Stable `EventListener` interface objects (one `addEventListener` per event, ever)
|
|
124
|
+
* - Previous-props `Map` tracking for efficient diffing
|
|
116
125
|
*
|
|
117
126
|
* @example
|
|
118
127
|
* ```tsx
|
|
@@ -126,108 +135,141 @@ const SPECIAL_PROPS = new Set(['className', 'style', 'children']);
|
|
|
126
135
|
* ref={ref}
|
|
127
136
|
* admissionToken="tok_..."
|
|
128
137
|
* onStatusChange={(e) => console.log(e.detail.newStatus)}
|
|
129
|
-
* onReady={() => console.log('ready')}
|
|
130
138
|
* style={{ width: '100%', height: '100%' }}
|
|
131
139
|
* />
|
|
132
140
|
* );
|
|
133
141
|
* }
|
|
134
142
|
* ```
|
|
135
143
|
*/
|
|
136
|
-
export const PixelStream = forwardRef(function PixelStream(props, ref) {
|
|
144
|
+
export const PixelStream = /*@__PURE__*/ forwardRef(function PixelStream(props, ref) {
|
|
137
145
|
const elementRef = useRef(null);
|
|
138
|
-
const
|
|
139
|
-
//
|
|
140
|
-
|
|
146
|
+
const prevPropsRef = useRef(new Map());
|
|
147
|
+
// Stable EventListener-interface objects: { handleEvent: fn }
|
|
148
|
+
// One addEventListener per event name, ever. On re-render we just swap handleEvent.
|
|
149
|
+
const eventHandlersRef = useRef(new Map());
|
|
150
|
+
// Merge internal + forwarded ref (Lit pattern)
|
|
151
|
+
const mergedRef = useCallback((node) => {
|
|
152
|
+
elementRef.current = node;
|
|
153
|
+
if (typeof ref === 'function') {
|
|
154
|
+
ref(node);
|
|
155
|
+
}
|
|
156
|
+
else if (ref && typeof ref === 'object') {
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
ref.current = node;
|
|
159
|
+
}
|
|
160
|
+
}, [ref]);
|
|
141
161
|
// Register the web component on first client mount
|
|
142
162
|
useEffect(() => {
|
|
143
163
|
ensureRegistered();
|
|
144
|
-
setMounted(true);
|
|
145
164
|
}, []);
|
|
146
|
-
// Sync
|
|
147
|
-
|
|
148
|
-
// teardown/re-add listeners on every render — only when the set of
|
|
149
|
-
// subscribed event names changes.
|
|
150
|
-
const propsRef = useRef(props);
|
|
151
|
-
propsRef.current = props;
|
|
152
|
-
// Compute which event names are currently subscribed
|
|
153
|
-
const activeEvents = Object.keys(EVENT_MAP)
|
|
154
|
-
.filter(k => typeof props[k] === 'function')
|
|
155
|
-
.join(',');
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
const el = elementRef.current;
|
|
158
|
-
if (!el || !mounted)
|
|
159
|
-
return;
|
|
160
|
-
const listeners = [];
|
|
161
|
-
for (const [propName, eventName] of Object.entries(EVENT_MAP)) {
|
|
162
|
-
const handler = propsRef.current[propName];
|
|
163
|
-
if (typeof handler !== 'function')
|
|
164
|
-
continue;
|
|
165
|
-
// Wrap so we always call the latest prop version
|
|
166
|
-
const wrapped = (e) => {
|
|
167
|
-
const fn = propsRef.current[propName];
|
|
168
|
-
if (typeof fn === 'function')
|
|
169
|
-
fn(e);
|
|
170
|
-
};
|
|
171
|
-
el.addEventListener(eventName, wrapped);
|
|
172
|
-
listeners.push([eventName, wrapped]);
|
|
173
|
-
}
|
|
174
|
-
return () => {
|
|
175
|
-
for (const [eventName, handler] of listeners) {
|
|
176
|
-
el.removeEventListener(eventName, handler);
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
180
|
-
}, [mounted, activeEvents]);
|
|
181
|
-
// Sync HTML attributes from props
|
|
182
|
-
useEffect(() => {
|
|
165
|
+
// Sync properties + attributes + events (synchronous, before paint)
|
|
166
|
+
useIsomorphicLayoutEffect(() => {
|
|
183
167
|
const el = elementRef.current;
|
|
184
|
-
if (!el
|
|
168
|
+
if (!el)
|
|
185
169
|
return;
|
|
186
|
-
|
|
187
|
-
const desired = new Map();
|
|
170
|
+
const newProps = new Map();
|
|
188
171
|
for (const [propName, value] of Object.entries(props)) {
|
|
189
|
-
if (
|
|
172
|
+
if (REACT_PROPS.has(propName))
|
|
190
173
|
continue;
|
|
191
|
-
|
|
174
|
+
// --- Events ---
|
|
175
|
+
const eventName = EVENT_MAP[propName];
|
|
176
|
+
if (eventName !== undefined) {
|
|
177
|
+
let handler = eventHandlersRef.current.get(eventName);
|
|
178
|
+
if (typeof value === 'function') {
|
|
179
|
+
if (handler === undefined) {
|
|
180
|
+
// First time: create stable handler object, one addEventListener
|
|
181
|
+
handler = { handleEvent: value };
|
|
182
|
+
eventHandlersRef.current.set(eventName, handler);
|
|
183
|
+
el.addEventListener(eventName, handler);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Subsequent: just swap the function pointer
|
|
187
|
+
handler.handleEvent = value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (handler !== undefined) {
|
|
191
|
+
// Callback removed — tear down
|
|
192
|
+
el.removeEventListener(eventName, handler);
|
|
193
|
+
eventHandlersRef.current.delete(eventName);
|
|
194
|
+
}
|
|
192
195
|
continue;
|
|
193
|
-
const attrName = ATTR_MAP[propName] ?? propName;
|
|
194
|
-
if (BOOLEAN_ATTRS.has(propName)) {
|
|
195
|
-
if (value)
|
|
196
|
-
desired.set(attrName, '');
|
|
197
196
|
}
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
// --- Direct properties ---
|
|
198
|
+
const elProp = PROP_MAP[propName];
|
|
199
|
+
if (elProp !== undefined) {
|
|
200
|
+
const prev = prevPropsRef.current.get(propName);
|
|
201
|
+
if (value !== prev) {
|
|
202
|
+
el[elProp] = value;
|
|
203
|
+
}
|
|
204
|
+
newProps.set(propName, value);
|
|
205
|
+
continue;
|
|
200
206
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// --- Attribute-only props ---
|
|
208
|
+
const attrName = ATTR_ONLY_MAP[propName];
|
|
209
|
+
if (attrName !== undefined) {
|
|
210
|
+
const prev = prevPropsRef.current.get(propName);
|
|
211
|
+
if (value !== prev) {
|
|
212
|
+
if (BOOLEAN_ATTRS.has(propName)) {
|
|
213
|
+
if (value) {
|
|
214
|
+
el.setAttribute(attrName, '');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
el.removeAttribute(attrName);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (value != null) {
|
|
221
|
+
el.setAttribute(attrName, String(value));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
el.removeAttribute(attrName);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
newProps.set(propName, value);
|
|
228
|
+
continue;
|
|
206
229
|
}
|
|
207
230
|
}
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
// Unset any props that were present last render but are gone now
|
|
232
|
+
for (const [propName, prevValue] of prevPropsRef.current) {
|
|
233
|
+
if (newProps.has(propName))
|
|
234
|
+
continue;
|
|
235
|
+
const elProp = PROP_MAP[propName];
|
|
236
|
+
if (elProp !== undefined) {
|
|
237
|
+
el[elProp] = undefined;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const attrName = ATTR_ONLY_MAP[propName];
|
|
241
|
+
if (attrName !== undefined) {
|
|
215
242
|
el.removeAttribute(attrName);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Removed event callback
|
|
246
|
+
const eventName = EVENT_MAP[propName];
|
|
247
|
+
if (eventName !== undefined && prevValue !== undefined) {
|
|
248
|
+
const handler = eventHandlersRef.current.get(eventName);
|
|
249
|
+
if (handler) {
|
|
250
|
+
el.removeEventListener(eventName, handler);
|
|
251
|
+
eventHandlersRef.current.delete(eventName);
|
|
252
|
+
}
|
|
216
253
|
}
|
|
217
254
|
}
|
|
255
|
+
prevPropsRef.current = newProps;
|
|
218
256
|
});
|
|
219
|
-
//
|
|
220
|
-
|
|
257
|
+
// Clean up all event listeners on unmount
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
return () => {
|
|
260
|
+
const el = elementRef.current;
|
|
261
|
+
if (!el)
|
|
262
|
+
return;
|
|
263
|
+
for (const [eventName, handler] of eventHandlersRef.current) {
|
|
264
|
+
el.removeEventListener(eventName, handler);
|
|
265
|
+
}
|
|
266
|
+
eventHandlersRef.current.clear();
|
|
267
|
+
};
|
|
268
|
+
}, []);
|
|
221
269
|
return React.createElement('pixel-stream', {
|
|
222
|
-
ref:
|
|
270
|
+
ref: mergedRef,
|
|
223
271
|
class: props.className,
|
|
224
|
-
style: props.style
|
|
225
|
-
? Object.entries(props.style).reduce((css, [k, v]) => {
|
|
226
|
-
const kebab = k.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
|
|
227
|
-
return `${css}${kebab}:${v};`;
|
|
228
|
-
}, '')
|
|
229
|
-
: undefined,
|
|
230
|
-
// Suppress React hydration warnings for attributes set by effects
|
|
272
|
+
style: props.style,
|
|
231
273
|
suppressHydrationWarning: true,
|
|
232
274
|
}, props.children);
|
|
233
275
|
});
|