@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 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