@appstrata/react 0.1.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/README.md +863 -0
- package/dist/deep-equal.d.ts +10 -0
- package/dist/deep-equal.d.ts.map +1 -0
- package/dist/deep-equal.js +28 -0
- package/dist/hooks.d.ts +207 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +321 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/provider.d.ts +56 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +52 -0
- package/dist/store.d.ts +33 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +72 -0
- package/dist/use-interaction.d.ts +35 -0
- package/dist/use-interaction.d.ts.map +1 -0
- package/dist/use-interaction.js +46 -0
- package/dist/use-media-cache.d.ts +56 -0
- package/dist/use-media-cache.d.ts.map +1 -0
- package/dist/use-media-cache.js +100 -0
- package/dist/use-proxy.d.ts +68 -0
- package/dist/use-proxy.d.ts.map +1 -0
- package/dist/use-proxy.js +90 -0
- package/dist/use-static.d.ts +36 -0
- package/dist/use-static.d.ts.map +1 -0
- package/dist/use-static.js +45 -0
- package/dist/use-storage.d.ts +72 -0
- package/dist/use-storage.d.ts.map +1 -0
- package/dist/use-storage.js +161 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
# @appstrata/react
|
|
2
|
+
|
|
3
|
+
React hooks for building digital signage apps with the AppStrata SDK. Provides a clean, reactive API on top of `@appstrata/core` and `@appstrata/web`.
|
|
4
|
+
|
|
5
|
+
**Requires React 18 or later.**
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
> **Note:** This is a private npm package. Requires an npm access token — contact the AppStrata team to get one, then add it to your `~/.npmrc`:
|
|
10
|
+
>
|
|
11
|
+
> ```
|
|
12
|
+
> //registry.npmjs.org/:_authToken=YOUR_TOKEN
|
|
13
|
+
> ```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @appstrata/react @appstrata/web @appstrata/core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
Wrap your app with `AppStrataProvider` and use hooks inside your components:
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { createRoot } from "react-dom/client";
|
|
25
|
+
import { AppStrataProvider, useAppContext, useLifecycle } from "@appstrata/react";
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
const context = useAppContext();
|
|
29
|
+
|
|
30
|
+
useLifecycle({
|
|
31
|
+
onStart: () => console.log("App started!"),
|
|
32
|
+
onStop: () => console.log("App stopped!"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!context) return <div>Loading...</div>;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div>
|
|
39
|
+
<h1>{context.config.title as string}</h1>
|
|
40
|
+
<p>Playing on: {context.device.name}</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const root = createRoot(document.getElementById("root")!);
|
|
46
|
+
root.render(
|
|
47
|
+
<AppStrataProvider>
|
|
48
|
+
<App />
|
|
49
|
+
</AppStrataProvider>
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## API Reference
|
|
56
|
+
|
|
57
|
+
### Provider
|
|
58
|
+
|
|
59
|
+
#### `<AppStrataProvider>`
|
|
60
|
+
|
|
61
|
+
Required wrapper for all AppStrata hooks. Initializes the player connection and manages context state.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { AppStrataProvider } from "@appstrata/react";
|
|
65
|
+
|
|
66
|
+
root.render(
|
|
67
|
+
<AppStrataProvider>
|
|
68
|
+
<App />
|
|
69
|
+
</AppStrataProvider>
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Props:**
|
|
74
|
+
|
|
75
|
+
| Prop | Type | Description |
|
|
76
|
+
|------|------|-------------|
|
|
77
|
+
| `children` | `ReactNode` | Child components |
|
|
78
|
+
| `player` | `SignagePlayer` (optional) | Custom player instance. Defaults to `getPlayer()` singleton from `@appstrata/web`. Useful for testing. |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### Core Hooks
|
|
83
|
+
|
|
84
|
+
#### `useAppContext()`
|
|
85
|
+
|
|
86
|
+
Access the complete `AppContext`. Re-renders on **any** context change. Returns `null` before the player initializes.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
function App() {
|
|
90
|
+
const context = useAppContext();
|
|
91
|
+
if (!context) return <div>Loading...</div>;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div style={{ width: context.viewportWidth, height: context.viewportHeight }}>
|
|
95
|
+
<h1>{context.config.title as string}</h1>
|
|
96
|
+
<p>Instance: {context.instanceId}</p>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### `useConfig<T>()`
|
|
103
|
+
|
|
104
|
+
Access the app configuration object. Only re-renders when `config` actually changes (deep comparison).
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
interface MyConfig {
|
|
108
|
+
title: string;
|
|
109
|
+
bgcolor: string;
|
|
110
|
+
refreshInterval: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function Header() {
|
|
114
|
+
const config = useConfig<MyConfig>();
|
|
115
|
+
if (!config) return null;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<h1 style={{ background: config.bgcolor }}>
|
|
119
|
+
{config.title}
|
|
120
|
+
</h1>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### `useResources()`
|
|
126
|
+
|
|
127
|
+
Access the resources array. Only re-renders when resources change (deep comparison).
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
function ResourceLoader() {
|
|
131
|
+
const resources = useResources();
|
|
132
|
+
const [loaded, setLoaded] = useState(false);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!resources) return;
|
|
136
|
+
setLoaded(false);
|
|
137
|
+
loadFonts(resources).then(() => setLoaded(true));
|
|
138
|
+
}, [resources]);
|
|
139
|
+
|
|
140
|
+
if (!loaded) return <div>Loading resources...</div>;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Resources include fonts, images, and other assets configured in the CMS:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
function FontLoader() {
|
|
149
|
+
const resources = useResources();
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!resources) return;
|
|
153
|
+
const fonts = resources.filter(r => r.category === "font");
|
|
154
|
+
fonts.forEach(font => {
|
|
155
|
+
const link = document.createElement("link");
|
|
156
|
+
link.rel = "stylesheet";
|
|
157
|
+
link.href = font.source;
|
|
158
|
+
document.head.appendChild(link);
|
|
159
|
+
});
|
|
160
|
+
}, [resources]);
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### `useDevice()`
|
|
167
|
+
|
|
168
|
+
Access device information. Only re-renders when device info changes (deep comparison).
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
function DeviceFooter() {
|
|
172
|
+
const device = useDevice();
|
|
173
|
+
if (!device) return null;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<footer>
|
|
177
|
+
<p>Device: {device.name}</p>
|
|
178
|
+
<p>Timezone: {device.timezone}</p>
|
|
179
|
+
<p>Locale: {device.locale}</p>
|
|
180
|
+
</footer>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### `usePlayer()`
|
|
186
|
+
|
|
187
|
+
Access the raw `SignagePlayer` instance for imperative operations.
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
function CompletionButton() {
|
|
191
|
+
const player = usePlayer();
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<button onClick={() => player.notifyComplete()}>
|
|
195
|
+
Done
|
|
196
|
+
</button>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### `useCapability(capability)`
|
|
202
|
+
|
|
203
|
+
Check if a capability is available. Returns a tri-state value:
|
|
204
|
+
|
|
205
|
+
- `null` -- player hasn't initialized yet
|
|
206
|
+
- `true` -- capability is supported
|
|
207
|
+
- `false` -- capability is NOT supported
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
function StorageStatus() {
|
|
211
|
+
const hasStorage = useCapability("storage");
|
|
212
|
+
|
|
213
|
+
if (hasStorage === null) return <span>Initializing...</span>;
|
|
214
|
+
if (hasStorage) return <span>Storage available</span>;
|
|
215
|
+
return <span>Storage not available</span>;
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Since `null` is falsy, simple boolean checks work naturally:
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
const hasProxy = useCapability("proxy");
|
|
223
|
+
if (hasProxy) {
|
|
224
|
+
// Only runs when initialized AND supported
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### `useLifecycle(callbacks)`
|
|
229
|
+
|
|
230
|
+
Subscribe to player lifecycle events. Your callbacks always see the latest state -- no need to memoize them with `useCallback`. Callbacks can optionally return a cleanup function that runs on unmount.
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
function AnimatedWidget() {
|
|
234
|
+
useLifecycle({
|
|
235
|
+
onShow: () => {
|
|
236
|
+
console.log("Widget became visible");
|
|
237
|
+
},
|
|
238
|
+
onStart: () => {
|
|
239
|
+
console.log("Starting animations");
|
|
240
|
+
startAnimations();
|
|
241
|
+
return () => {
|
|
242
|
+
// Cleanup: called on unmount
|
|
243
|
+
stopAnimations();
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
onHide: () => {
|
|
247
|
+
console.log("Widget hidden");
|
|
248
|
+
},
|
|
249
|
+
onStop: () => {
|
|
250
|
+
console.log("Widget stopped");
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return <div className="animated-content">...</div>;
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Transient phases (`onShow` / `onHide`):** Because `useLifecycle` registers handlers inside a `useEffect` (which runs after paint), handlers for transient phases like `Show` and `Hide` may not fire if the phase has already passed by the time the effect runs. This commonly happens after HMR updates — the component re-mounts while the lifecycle is already in the `Start` phase, so the late subscriber for `onShow` doesn't fire (only `onStart` does, since that's the current phase).
|
|
259
|
+
|
|
260
|
+
For work that must not be skipped, use `onStart` / `onStop` instead. Alternatively, use `useLifecyclePhase()` to derive visibility state from the current phase:
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
const phase = useLifecyclePhase();
|
|
264
|
+
const isVisible = phase === LifecyclePhase.Show || phase === LifecyclePhase.Start;
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### Granular Re-Render Optimization
|
|
270
|
+
|
|
271
|
+
The core context hooks (`useConfig`, `useResources`, `useDevice`) use deep comparison internally. Each hook subscribes to its own slice of the context store, so a component only re-renders when the data it actually uses has changed -- even if the player sends a completely new context object.
|
|
272
|
+
|
|
273
|
+
For best results, put each granular hook in its own component:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
function OptimizedApp() {
|
|
277
|
+
return (
|
|
278
|
+
<div>
|
|
279
|
+
<Header />
|
|
280
|
+
<DeviceFooter />
|
|
281
|
+
<ResourceLoader />
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Header() {
|
|
287
|
+
// Only re-renders when config changes
|
|
288
|
+
const config = useConfig<{ title: string; bgcolor: string }>();
|
|
289
|
+
if (!config) return null;
|
|
290
|
+
return <h1 style={{ background: config.bgcolor }}>{config.title}</h1>;
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
### Data-Fetching Hooks
|
|
297
|
+
|
|
298
|
+
All data-fetching hooks share a common pattern:
|
|
299
|
+
|
|
300
|
+
- They wait for the player to initialize before fetching
|
|
301
|
+
- They return a `loading` flag (`true` before init and during fetch)
|
|
302
|
+
- They return a `supported` tri-state (`null | boolean`)
|
|
303
|
+
- They support an optional `fallback` for when the capability is unsupported
|
|
304
|
+
- Pass `url: null` (or `key: null`) to skip fetching
|
|
305
|
+
|
|
306
|
+
#### `useProxy<T>(url, options?)`
|
|
307
|
+
|
|
308
|
+
Fetch data through the player's HTTP proxy with caching support.
|
|
309
|
+
|
|
310
|
+
**Basic usage:**
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
interface WeatherData {
|
|
314
|
+
temperature: number;
|
|
315
|
+
condition: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function WeatherWidget() {
|
|
319
|
+
const { data, loading, error } = useProxy<WeatherData>(
|
|
320
|
+
"https://api.weather.com/current?city=NYC"
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (loading) return <div>Loading weather...</div>;
|
|
324
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
325
|
+
if (!data) return null;
|
|
326
|
+
|
|
327
|
+
return <div>{data.temperature}F - {data.condition}</div>;
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**With full options (method, headers, body, cache):**
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
const { data } = useProxy<SearchResult[]>(
|
|
335
|
+
"https://api.example.com/search",
|
|
336
|
+
{
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: { "Content-Type": "application/json" },
|
|
339
|
+
body: JSON.stringify({ query: searchTerm }),
|
|
340
|
+
cache: { maxAge: 60 },
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**With fallback for unsupported players:**
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
const { data, loading } = useProxy<WeatherData>(
|
|
349
|
+
"https://api.weather.com/current",
|
|
350
|
+
{
|
|
351
|
+
cache: { maxAge: 300 },
|
|
352
|
+
fallback: (req) =>
|
|
353
|
+
fetch(req.url, {
|
|
354
|
+
method: req.method,
|
|
355
|
+
headers: req.headers,
|
|
356
|
+
body: req.body,
|
|
357
|
+
}).then(r => r.json()),
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
// Works on all players -- uses proxy when available, falls back to direct fetch
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Conditional fetching:**
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
const config = useConfig<{ lat: number; lon: number }>();
|
|
367
|
+
|
|
368
|
+
// Only fetch when config is available
|
|
369
|
+
const { data } = useProxy<WeatherData>(
|
|
370
|
+
config ? `https://api.weather.com/current?lat=${config.lat}&lon=${config.lon}` : null
|
|
371
|
+
);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Manual refetch:**
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
const { data, refetch } = useProxy<StockData>("https://api.stocks.com/AAPL");
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<div>
|
|
381
|
+
<p>Price: {data?.price}</p>
|
|
382
|
+
<button onClick={refetch}>Refresh</button>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Return type:**
|
|
388
|
+
|
|
389
|
+
| Field | Type | Description |
|
|
390
|
+
|-------|------|-------------|
|
|
391
|
+
| `data` | `T \| null` | Parsed response data |
|
|
392
|
+
| `loading` | `boolean` | Whether a fetch is in progress (or waiting for init) |
|
|
393
|
+
| `error` | `Error \| null` | Error from the last fetch attempt |
|
|
394
|
+
| `supported` | `boolean \| null` | Proxy capability status |
|
|
395
|
+
| `refetch` | `() => Promise<void>` | Manually trigger a refetch |
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
#### `useStorage(key, defaultValue?, options?)` -- Single-Key Mode
|
|
400
|
+
|
|
401
|
+
Reactive storage for a single key. Auto-loads the value after init and manages loading state.
|
|
402
|
+
|
|
403
|
+
**Basic usage:**
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
function ThemeSelector() {
|
|
407
|
+
const { value: theme, loading, set } = useStorage<string>("theme", "light");
|
|
408
|
+
|
|
409
|
+
if (loading) return <div>Loading...</div>;
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<div className={`theme-${theme}`}>
|
|
413
|
+
<button onClick={() => set("dark")}>Dark</button>
|
|
414
|
+
<button onClick={() => set("light")}>Light</button>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**With fallback to localStorage:**
|
|
421
|
+
|
|
422
|
+
```tsx
|
|
423
|
+
const { value, set, remove } = useStorage<string>("theme", "dark", {
|
|
424
|
+
fallback: {
|
|
425
|
+
get: (key) => JSON.parse(localStorage.getItem(key) ?? "undefined"),
|
|
426
|
+
set: (key, val) => localStorage.setItem(key, JSON.stringify(val)),
|
|
427
|
+
remove: (key) => localStorage.removeItem(key),
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
// Works on all players -- uses player storage when available, localStorage otherwise
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**Persisting slideshow position:**
|
|
434
|
+
|
|
435
|
+
```tsx
|
|
436
|
+
function SlideshowApp() {
|
|
437
|
+
const { value: currentSlide, loading, set: saveSlide } = useStorage<number>(
|
|
438
|
+
"currentSlide",
|
|
439
|
+
0, // Start at slide 0 by default
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
useLifecycle({
|
|
443
|
+
onStop: () => {
|
|
444
|
+
if (currentSlide !== undefined) saveSlide(currentSlide);
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (loading) return <div>Loading...</div>;
|
|
449
|
+
return <Slide index={currentSlide ?? 0} />;
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
**Return type (single-key):**
|
|
454
|
+
|
|
455
|
+
| Field | Type | Description |
|
|
456
|
+
|-------|------|-------------|
|
|
457
|
+
| `value` | `T \| undefined` | Current value (defaultValue while loading) |
|
|
458
|
+
| `loading` | `boolean` | Whether the initial load is in progress |
|
|
459
|
+
| `supported` | `boolean \| null` | Storage capability status |
|
|
460
|
+
| `set` | `(value: T) => Promise<void>` | Update the value |
|
|
461
|
+
| `remove` | `() => Promise<void>` | Remove the key (resets to defaultValue) |
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
#### `useStorage(options?)` -- Full API Mode
|
|
466
|
+
|
|
467
|
+
Imperative access to all storage operations. No auto-loading -- you call the methods yourself.
|
|
468
|
+
|
|
469
|
+
```tsx
|
|
470
|
+
function StorageManager() {
|
|
471
|
+
const { get, set, remove, list, clear, supported } = useStorage();
|
|
472
|
+
|
|
473
|
+
const handleExport = async () => {
|
|
474
|
+
const keys = await list();
|
|
475
|
+
const data: Record<string, unknown> = {};
|
|
476
|
+
for (const key of keys) {
|
|
477
|
+
data[key] = await get(key);
|
|
478
|
+
}
|
|
479
|
+
console.log("All storage:", data);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const handleClearAll = async () => {
|
|
483
|
+
await clear();
|
|
484
|
+
console.log("Storage cleared");
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<div>
|
|
489
|
+
<button onClick={handleExport}>Export Data</button>
|
|
490
|
+
<button onClick={handleClearAll}>Clear All</button>
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**With fallback adapter:**
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
const { get, set, list, clear } = useStorage({
|
|
500
|
+
fallback: {
|
|
501
|
+
get: (key) => JSON.parse(localStorage.getItem(key) ?? "undefined"),
|
|
502
|
+
set: (key, val) => localStorage.setItem(key, JSON.stringify(val)),
|
|
503
|
+
remove: (key) => localStorage.removeItem(key),
|
|
504
|
+
list: () => Object.keys(localStorage),
|
|
505
|
+
clear: () => localStorage.clear(),
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**Return type (full API):**
|
|
511
|
+
|
|
512
|
+
| Field | Type | Description |
|
|
513
|
+
|-------|------|-------------|
|
|
514
|
+
| `supported` | `boolean \| null` | Storage capability status |
|
|
515
|
+
| `get` | `<T>(key: string) => Promise<T \| undefined>` | Get a value |
|
|
516
|
+
| `set` | `(key: string, value: unknown) => Promise<void>` | Set a value |
|
|
517
|
+
| `remove` | `(key: string) => Promise<void>` | Remove a key |
|
|
518
|
+
| `list` | `() => Promise<string[]>` | List all keys |
|
|
519
|
+
| `clear` | `() => Promise<void>` | Clear all storage |
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
#### `useMediaCache(url, options?)`
|
|
524
|
+
|
|
525
|
+
Download and cache media files. Returns a `file` object with a `localPath` that is always usable.
|
|
526
|
+
|
|
527
|
+
**Basic usage (works everywhere):**
|
|
528
|
+
|
|
529
|
+
```tsx
|
|
530
|
+
function HeroVideo() {
|
|
531
|
+
const { file, loading } = useMediaCache("https://example.com/hero.mp4");
|
|
532
|
+
|
|
533
|
+
if (loading) return <div>Downloading...</div>;
|
|
534
|
+
if (!file) return null;
|
|
535
|
+
|
|
536
|
+
// file.localPath is always usable:
|
|
537
|
+
// - When supported: points to local cached file
|
|
538
|
+
// - When unsupported: auto-passthrough to the original URL
|
|
539
|
+
return <video src={file.localPath} autoPlay loop />;
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**With custom fallback:**
|
|
544
|
+
|
|
545
|
+
```tsx
|
|
546
|
+
const { file, loading } = useMediaCache("https://example.com/hero.mp4", {
|
|
547
|
+
fallback: async (url) => ({
|
|
548
|
+
md5: "",
|
|
549
|
+
localPath: url, // Use original URL
|
|
550
|
+
fileName: "hero.mp4",
|
|
551
|
+
size: 0,
|
|
552
|
+
status: "cached" as const,
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**Conditional download:**
|
|
558
|
+
|
|
559
|
+
```tsx
|
|
560
|
+
const config = useConfig<{ videoUrl?: string }>();
|
|
561
|
+
|
|
562
|
+
const { file, loading, error } = useMediaCache(config?.videoUrl ?? null);
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**Manual re-download:**
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
const { file, refetch } = useMediaCache("https://example.com/data.json");
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<div>
|
|
572
|
+
{file && <pre>{file.localPath}</pre>}
|
|
573
|
+
<button onClick={refetch}>Re-download</button>
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
**Return type:**
|
|
579
|
+
|
|
580
|
+
| Field | Type | Description |
|
|
581
|
+
|-------|------|-------------|
|
|
582
|
+
| `file` | `MediaFile \| null` | Cached file info (with usable `localPath`) |
|
|
583
|
+
| `loading` | `boolean` | Whether a download is in progress |
|
|
584
|
+
| `error` | `Error \| null` | Error from the last download attempt |
|
|
585
|
+
| `supported` | `boolean \| null` | MediaCache capability status |
|
|
586
|
+
| `refetch` | `() => Promise<void>` | Manually trigger a re-download |
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
### Convenience Hooks
|
|
591
|
+
|
|
592
|
+
#### `useStatic()`
|
|
593
|
+
|
|
594
|
+
Control static/semi-static rendering optimization. All methods are safe no-ops when the capability is unsupported.
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
function StaticContent() {
|
|
598
|
+
const config = useConfig<{ content: string; enableAnimations: boolean }>();
|
|
599
|
+
const { markStatic, markSemiStatic, disableStatic } = useStatic();
|
|
600
|
+
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
if (!config) return;
|
|
603
|
+
|
|
604
|
+
if (config.enableAnimations) {
|
|
605
|
+
disableStatic(); // Dynamic content, no screenshot optimization
|
|
606
|
+
} else {
|
|
607
|
+
markStatic(); // Content is static, player can take a screenshot
|
|
608
|
+
}
|
|
609
|
+
}, [config]);
|
|
610
|
+
|
|
611
|
+
if (!config) return <div>Loading...</div>;
|
|
612
|
+
return <div>{config.content}</div>;
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Semi-static content (refreshes periodically):**
|
|
617
|
+
|
|
618
|
+
```tsx
|
|
619
|
+
function ClockWidget() {
|
|
620
|
+
const { markSemiStatic } = useStatic();
|
|
621
|
+
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
markSemiStatic(60); // Refresh screenshot every 60 seconds
|
|
624
|
+
}, []);
|
|
625
|
+
|
|
626
|
+
return <div>{new Date().toLocaleTimeString()}</div>;
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
**Return type:**
|
|
631
|
+
|
|
632
|
+
| Field | Type | Description |
|
|
633
|
+
|-------|------|-------------|
|
|
634
|
+
| `supported` | `boolean \| null` | Static capability status |
|
|
635
|
+
| `markStatic` | `() => void` | Signal that content is ready for static capture |
|
|
636
|
+
| `markSemiStatic` | `(refreshIntervalSeconds: number) => void` | Signal semi-static with refresh interval |
|
|
637
|
+
| `disableStatic` | `() => void` | Disable static mode |
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
#### `useInteraction(handler)`
|
|
642
|
+
|
|
643
|
+
Subscribe to hardware interaction events (buttons, remote controls, sensors). Auto-subscribes on mount, auto-unsubscribes on unmount. No-op when unsupported.
|
|
644
|
+
|
|
645
|
+
```tsx
|
|
646
|
+
function InteractiveMenu() {
|
|
647
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
648
|
+
|
|
649
|
+
const { supported } = useInteraction((event) => {
|
|
650
|
+
if (event.type === "key") {
|
|
651
|
+
if (event.key === "up") setSelectedIndex(i => Math.max(0, i - 1));
|
|
652
|
+
if (event.key === "down") setSelectedIndex(i => i + 1);
|
|
653
|
+
if (event.key === "enter") handleSelect(selectedIndex);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<div>
|
|
659
|
+
<Menu selectedIndex={selectedIndex} />
|
|
660
|
+
{!supported && <p>Touch/click to navigate</p>}
|
|
661
|
+
</div>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
**Return type:**
|
|
667
|
+
|
|
668
|
+
| Field | Type | Description |
|
|
669
|
+
|-------|------|-------------|
|
|
670
|
+
| `supported` | `boolean \| null` | Interaction capability status |
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Capabilities
|
|
675
|
+
|
|
676
|
+
The AppStrata SDK defines optional capabilities that a player may or may not support:
|
|
677
|
+
|
|
678
|
+
| Capability | Hook | Description |
|
|
679
|
+
|------------|------|-------------|
|
|
680
|
+
| `storage` | `useStorage()` | Key-value persistent storage |
|
|
681
|
+
| `proxy` | `useProxy()` | HTTP proxy with caching |
|
|
682
|
+
| `mediaCache` | `useMediaCache()` | Media file download and caching |
|
|
683
|
+
| `static` | `useStatic()` | Static/semi-static rendering optimization |
|
|
684
|
+
| `interaction` | `useInteraction()` | Hardware buttons, remote controls, sensors |
|
|
685
|
+
|
|
686
|
+
All capability hooks handle unsupported scenarios gracefully:
|
|
687
|
+
|
|
688
|
+
- **`useProxy`** and **`useStorage`**: Accept a `fallback` option for custom alternative logic
|
|
689
|
+
- **`useMediaCache`**: Auto-passthrough (uses original URL as `localPath`) when unsupported
|
|
690
|
+
- **`useStatic`**: Methods become safe no-ops
|
|
691
|
+
- **`useInteraction`**: Silently does nothing
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## The `supported` Tri-State
|
|
696
|
+
|
|
697
|
+
All capability hooks return a `supported` field with type `boolean | null`:
|
|
698
|
+
|
|
699
|
+
| Value | Meaning |
|
|
700
|
+
|-------|---------|
|
|
701
|
+
| `null` | Player hasn't initialized yet (waiting for READY message) |
|
|
702
|
+
| `true` | Initialized and capability is supported |
|
|
703
|
+
| `false` | Initialized and capability is NOT supported |
|
|
704
|
+
|
|
705
|
+
**For most apps, you don't need to check `supported` at all.** The hooks handle initialization and fallback internally. The `loading` flag is your universal gate:
|
|
706
|
+
|
|
707
|
+
```tsx
|
|
708
|
+
const { data, loading } = useProxy<MyData>("https://api.example.com/data", {
|
|
709
|
+
fallback: (req) => fetch(req.url).then(r => r.json()),
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (loading) return <Spinner />;
|
|
713
|
+
return <Display data={data} />;
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
The tri-state is available for power users who need to distinguish "not yet initialized" from "genuinely unsupported."
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## Complete Example
|
|
721
|
+
|
|
722
|
+
```tsx
|
|
723
|
+
import {
|
|
724
|
+
AppStrataProvider,
|
|
725
|
+
useAppContext,
|
|
726
|
+
useConfig,
|
|
727
|
+
useDevice,
|
|
728
|
+
useResources,
|
|
729
|
+
useLifecycle,
|
|
730
|
+
useProxy,
|
|
731
|
+
useStorage,
|
|
732
|
+
useMediaCache,
|
|
733
|
+
useStatic,
|
|
734
|
+
} from "@appstrata/react";
|
|
735
|
+
|
|
736
|
+
interface AppConfig {
|
|
737
|
+
title: string;
|
|
738
|
+
bgcolor: string;
|
|
739
|
+
apiUrl: string;
|
|
740
|
+
heroVideo: string;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function App() {
|
|
744
|
+
const context = useAppContext();
|
|
745
|
+
|
|
746
|
+
useLifecycle({
|
|
747
|
+
onStart: () => console.log("App started"),
|
|
748
|
+
onStop: () => console.log("App stopped"),
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
if (!context) return <div className="loading">Initializing...</div>;
|
|
752
|
+
|
|
753
|
+
return (
|
|
754
|
+
<div className="app">
|
|
755
|
+
<Header />
|
|
756
|
+
<Content />
|
|
757
|
+
<Footer />
|
|
758
|
+
</div>
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function Header() {
|
|
763
|
+
const config = useConfig<AppConfig>();
|
|
764
|
+
if (!config) return null;
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<header style={{ background: config.bgcolor }}>
|
|
768
|
+
<h1>{config.title}</h1>
|
|
769
|
+
</header>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function Content() {
|
|
774
|
+
const config = useConfig<AppConfig>();
|
|
775
|
+
|
|
776
|
+
// Fetch API data with fallback
|
|
777
|
+
const { data, loading: apiLoading } = useProxy<{ items: string[] }>(
|
|
778
|
+
config?.apiUrl ?? null,
|
|
779
|
+
{
|
|
780
|
+
cache: { maxAge: 300 },
|
|
781
|
+
fallback: (req) => fetch(req.url).then(r => r.json()),
|
|
782
|
+
}
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
// Cache hero video locally
|
|
786
|
+
const { file: heroFile, loading: videoLoading } = useMediaCache(
|
|
787
|
+
config?.heroVideo ?? null
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
// Persist scroll position
|
|
791
|
+
const { value: scrollPos, set: saveScroll } = useStorage<number>("scrollPos", 0);
|
|
792
|
+
|
|
793
|
+
// Mark as semi-static (refresh every 5 minutes)
|
|
794
|
+
const { markSemiStatic } = useStatic();
|
|
795
|
+
useEffect(() => { markSemiStatic(300); }, []);
|
|
796
|
+
|
|
797
|
+
if (apiLoading || videoLoading) return <div>Loading content...</div>;
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div>
|
|
801
|
+
{heroFile && <video src={heroFile.localPath} autoPlay loop muted />}
|
|
802
|
+
<ul>
|
|
803
|
+
{data?.items.map((item, i) => <li key={i}>{item}</li>)}
|
|
804
|
+
</ul>
|
|
805
|
+
</div>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function Footer() {
|
|
810
|
+
const device = useDevice();
|
|
811
|
+
const resources = useResources();
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
<footer>
|
|
815
|
+
{device && <span>Playing on: {device.name}</span>}
|
|
816
|
+
{resources && <span>{resources.length} resources loaded</span>}
|
|
817
|
+
</footer>
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Entry point
|
|
822
|
+
const root = createRoot(document.getElementById("root")!);
|
|
823
|
+
root.render(
|
|
824
|
+
<AppStrataProvider>
|
|
825
|
+
<App />
|
|
826
|
+
</AppStrataProvider>
|
|
827
|
+
);
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
## Testing
|
|
833
|
+
|
|
834
|
+
The `AppStrataProvider` accepts an optional `player` prop, making it easy to inject a mock player for testing:
|
|
835
|
+
|
|
836
|
+
```tsx
|
|
837
|
+
import { AppStrataProvider } from "@appstrata/react";
|
|
838
|
+
import { renderHook, act } from "@testing-library/react";
|
|
839
|
+
|
|
840
|
+
const mockPlayer = createMockPlayer(); // Your mock implementation
|
|
841
|
+
|
|
842
|
+
function wrapper({ children }) {
|
|
843
|
+
return (
|
|
844
|
+
<AppStrataProvider player={mockPlayer}>
|
|
845
|
+
{children}
|
|
846
|
+
</AppStrataProvider>
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const { result } = renderHook(() => useConfig(), { wrapper });
|
|
851
|
+
act(() => simulateInit(mockPlayer));
|
|
852
|
+
expect(result.current).toEqual({ title: "Test" });
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
---
|
|
856
|
+
|
|
857
|
+
## Peer Dependencies
|
|
858
|
+
|
|
859
|
+
- `react` >= 18
|
|
860
|
+
|
|
861
|
+
## License
|
|
862
|
+
|
|
863
|
+
MIT
|