@shenora/react 0.12.0 → 0.13.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.
- package/dist/segmentBinder.js +42 -2
- package/dist/segmentStream.js +52 -11
- package/dist/store.js +36 -4
- package/package.json +1 -1
package/dist/segmentBinder.js
CHANGED
|
@@ -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);
|
package/dist/segmentStream.js
CHANGED
|
@@ -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 +
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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.
|
|
3
|
+
"version": "0.13.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",
|