@shenora/react 0.12.0 → 0.14.0

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.
@@ -169,6 +169,33 @@ export async function bindSegmentStream(options) {
169
169
  * completes, and two concurrent `appendBuffer` calls on one SourceBuffer throw `InvalidStateError`,
170
170
  * which surfaces as a stall with no obvious cause.
171
171
  */
172
+ /** Consecutive 503s, so a source that is slow to produce backs off instead of hammering. */
173
+ let waits = 0;
174
+ let waitTimer = null;
175
+ /**
176
+ * Come back for a segment the host has not finished producing.
177
+ *
178
+ * ⚠ Bounded, and it says so when it gives up: a wait that retries for ever is the same permanent
179
+ * spinner by a slower route. The cap is generous because the host's own 503 already means it is
180
+ * working — this is not a failure budget, it is a politeness one.
181
+ */
182
+ const retryLater = (uri) => {
183
+ if (disposed)
184
+ return;
185
+ if (++waits > 40) {
186
+ fail(`segments: ${uri} still answering 503 after ${waits} attempts — giving up`);
187
+ return;
188
+ }
189
+ const delay = Math.min(2000, 100 * waits);
190
+ say(`segments: ${uri} not ready (503) — retrying in ${delay}ms`);
191
+ if (waitTimer !== null)
192
+ clearTimeout(waitTimer);
193
+ waitTimer = setTimeout(() => {
194
+ waitTimer = null;
195
+ if (!disposed)
196
+ void pump();
197
+ }, delay);
198
+ };
172
199
  const pump = async () => {
173
200
  if (pumping || disposed)
174
201
  return;
@@ -184,12 +211,20 @@ export async function bindSegmentStream(options) {
184
211
  const response = await doFetch(resolve(manifestUrl, entry.uri));
185
212
  if (disposed)
186
213
  return;
214
+ if (response.status === 503) {
215
+ // 🔴 503 IS THE HOST SAYING "still producing" — a WAIT, and it must actually be waited FOR.
216
+ // This used to say exactly that in a comment and then `fail()` and break, with nothing
217
+ // scheduled to resume: a permanent spinner, on the one path the segment route uses to mean
218
+ // "ask again". The producing side waits its own budget before answering, so both halves said
219
+ // "wait" and neither came back.
220
+ retryLater(entry.uri);
221
+ break;
222
+ }
187
223
  if (!response.ok) {
188
- // 503 is the host saying "still producing" — a WAIT, not a failure. The route answers it
189
- // rather than 404ing a source that is merely not ready yet.
190
224
  fail(`segments: ${entry.uri} answered ${response.status}`);
191
225
  break;
192
226
  }
227
+ waits = 0; // progress: the next stall starts its backoff from the bottom again
193
228
  await append(new Uint8Array(await response.arrayBuffer()));
194
229
  appended.add(index);
195
230
  if (disposed)
@@ -227,6 +262,11 @@ export async function bindSegmentStream(options) {
227
262
  if (disposed)
228
263
  return;
229
264
  disposed = true;
265
+ // The 503 retry must not outlive the binding — it would re-enter a pump over a torn-down source.
266
+ if (waitTimer !== null) {
267
+ clearTimeout(waitTimer);
268
+ waitTimer = null;
269
+ }
230
270
  source.removeEventListener('startstreaming', onStart);
231
271
  source.removeEventListener('endstreaming', onEnd);
232
272
  element.removeEventListener('timeupdate', wake);
@@ -168,6 +168,48 @@ export function codecsFromInitSegment(init) {
168
168
  at += size;
169
169
  }
170
170
  };
171
+ /**
172
+ * An ISO 14496-1 "expandable" descriptor length: 1–4 bytes, each with the high bit meaning "continue".
173
+ * Returns the value and the offset just past it.
174
+ */
175
+ const expandable = (at) => {
176
+ let value = 0;
177
+ let p = at;
178
+ for (let i = 0; i < 4; i++) {
179
+ const b = u8(p++);
180
+ value = (value << 7) | (b & 0x7f);
181
+ if ((b & 0x80) === 0)
182
+ break;
183
+ }
184
+ return [value, p];
185
+ };
186
+ /**
187
+ * The AAC audio object type declared inside an `esds`, or null when it cannot be read.
188
+ *
189
+ * Tolerant by design: it finds the DecoderConfigDescriptor rather than parsing the ES_Descriptor's
190
+ * optional fields, then steps its FIXED 13 bytes to the nested DecoderSpecificInfo, whose first 5 bits
191
+ * are the object type (2 = AAC-LC, 5 = HE-AAC, 29 = HE-AACv2, 42 = xHE-AAC).
192
+ */
193
+ const audioObjectType = (from, to) => {
194
+ for (let p = from; p + 2 < to; p++) {
195
+ if (u8(p) !== 0x04)
196
+ continue; // DecoderConfigDescriptor
197
+ const [, afterLength] = expandable(p + 1);
198
+ if (afterLength >= to || u8(afterLength) !== 0x40)
199
+ continue; // MPEG-4 Audio, or not ours
200
+ // objectTypeIndication(1) streamType(1) bufferSizeDB(3) maxBitrate(4) avgBitrate(4)
201
+ const nested = afterLength + 13;
202
+ if (nested + 1 >= to || u8(nested) !== 0x05)
203
+ continue; // DecoderSpecificInfo
204
+ const [, config] = expandable(nested + 1);
205
+ if (config >= to)
206
+ continue;
207
+ const aot = u8(config) >> 3;
208
+ // 31 is the escape for an extended type; not worth decoding for a codec string, and 0 is invalid.
209
+ return aot === 0 || aot === 31 ? null : aot;
210
+ }
211
+ return null;
212
+ };
171
213
  /** The `avcC`/`hvcC`/`esds` inside a sample entry, whose own fields come first. */
172
214
  const configuration = (format, start, end) => {
173
215
  // The child boxes start after the sample entry's own fields: the 8-byte SampleEntry base
@@ -196,17 +238,16 @@ export function codecsFromInitSegment(init) {
196
238
  derived = `${format}.${spaces}${idc}.${(reversed >>> 0).toString(16).toUpperCase()}.` +
197
239
  `${tier ? 'H' : 'L'}${level}`;
198
240
  }
199
- else if (type === 'esds' && s + 21 <= end) {
200
- // The audio object type, from the DecoderSpecificInfo's first 5 bits. Only AAC is ever copied
201
- // here, so the walk stays a search rather than a full descriptor parser.
202
- for (let p = s; p + 2 < end; p++) {
203
- if (u8(p) === 0x04 && u8(p + 2) === 0x40) {
204
- derived = `mp4a.40.${u8(p + 3) || 2}`;
205
- break;
206
- }
207
- }
208
- if (!derived)
209
- derived = 'mp4a.40.2';
241
+ else if (type === 'esds' && s + 8 <= end) {
242
+ // 🔴 THE AUDIO OBJECT TYPE LIVES IN THE DecoderSpecificInfo, not beside objectTypeIndication.
243
+ // This used to read the byte straight after the `0x40`, which is the STREAM TYPE — for audio
244
+ // that is `(5 << 2) | 1` = 0x15, so every short-form esds produced `mp4a.40.21`,
245
+ // `addSourceBuffer` threw, and nothing played. It also assumed a ONE-byte descriptor length; the
246
+ // length is "expandable" (up to 4 bytes, high bit continues). The kit's own muxer always writes
247
+ // the 4-byte form, so the old scan never matched OUR files and the fallback hid it — the bug was
248
+ // reachable only from a foreign muxer, which is exactly what this parser is for.
249
+ const aot = audioObjectType(s, end);
250
+ derived = `mp4a.40.${aot ?? 2}`;
210
251
  }
211
252
  });
212
253
  if (derived)
package/dist/store.js CHANGED
@@ -13,12 +13,44 @@ function equivalent(a, b) {
13
13
  return true;
14
14
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
15
15
  return false;
16
- // Arrays and plain objects only — anything exotic (Map, Date, a class) falls back to identity above.
17
- if (Array.isArray(a) !== Array.isArray(b))
16
+ // Two different classes are never the same value, and this also separates an array from a bag.
17
+ const proto = Object.getPrototypeOf(a);
18
+ if (proto !== Object.getPrototypeOf(b))
18
19
  return false;
20
+ // 🔴 A Date, a Set and a Map keep their contents in INTERNAL SLOTS, so `Object.keys` is `[]` for every
21
+ // one of them — the key compare below then reports two of different contents EQUAL, which pinned such
22
+ // a selector to its first value for the component's life. Silent: the early return also skips the cache
23
+ // refresh, so it never self-corrects. These three answer for themselves.
24
+ if (a instanceof Date)
25
+ return Object.is(a.getTime(), b.getTime());
26
+ if (a instanceof Set) {
27
+ const setB = b;
28
+ if (a.size !== setB.size)
29
+ return false;
30
+ for (const value of a)
31
+ if (!setB.has(value))
32
+ return false;
33
+ return true;
34
+ }
35
+ if (a instanceof Map) {
36
+ const mapB = b;
37
+ if (a.size !== mapB.size)
38
+ return false;
39
+ for (const [key, value] of a) {
40
+ if (!mapB.has(key) || !Object.is(mapB.get(key), value))
41
+ return false;
42
+ }
43
+ return true;
44
+ }
19
45
  const aKeys = Object.keys(a);
20
- const bKeys = Object.keys(b);
21
- if (aKeys.length !== bKeys.length)
46
+ if (aKeys.length !== Object.keys(b).length)
47
+ return false;
48
+ // ⚠ ZERO own keys on anything else exotic — a RegExp, a Promise, a class whose state is all private —
49
+ // means this compare learned NOTHING, so it must not answer "equal". A plain bag or an array genuinely
50
+ // IS empty. Class instances carrying own fields still compare shallowly, which is why the gate is on
51
+ // emptiness rather than on being a plain object: rejecting those would trade a stale render for
52
+ // React's "getSnapshot should be cached" loop.
53
+ if (aKeys.length === 0 && !Array.isArray(a) && proto !== Object.prototype && proto !== null)
22
54
  return false;
23
55
  return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k)
24
56
  && Object.is(a[k], b[k]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",