@assetlib/sdk-expo 0.0.0-stage → 0.6.0-preview.1
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/LICENSE +21 -0
- package/README.md +253 -2
- package/package.json +50 -4
- package/src/index.tsx +246 -0
- package/src/platform.native.ts +183 -0
- package/src/platform.ts +1 -0
- package/src/platform.web.ts +169 -0
- package/src/shared.ts +50 -0
- package/src/telemetry.ts +68 -0
- package/src/video.tsx +118 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Assetlib contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,254 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @assetlib/sdk-expo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Expo SDK 57 / React Native 0.86 image delivery adapter for `@assetlib/sdk-core`. One integration targets iOS, Android, and Expo web. This is a preview SDK, not separate Swift or Kotlin SDKs.
|
|
4
|
+
|
|
5
|
+
Install **`@assetlib/sdk-core` and `@assetlib/sdk-expo` at the same exact version** as direct dependencies, from npm (`preview` dist-tag) or as the tarballs attached to the same GitHub release. The Expo package depends on that exact core version. The host app needs Expo 57, React 19, React Native 0.86, and matching `expo-file-system` and `expo-image` versions:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx expo install expo-file-system expo-image
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package ships Metro-ready TypeScript/TSX source. It has no install-time generation, lifecycle downloads, or secrets. Metro selects the native or web adapter; a custom bundler must honor `.native` / `.web` module resolution.
|
|
12
|
+
|
|
13
|
+
## Integration
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { createExpoAssetClient, AssetlibImage } from '@assetlib/sdk-expo';
|
|
17
|
+
import { parsePublicConfig } from '@assetlib/sdk-core';
|
|
18
|
+
import { AppAssets } from './assets.generated';
|
|
19
|
+
|
|
20
|
+
const client = createExpoAssetClient(parsePublicConfig(publicConfig));
|
|
21
|
+
await client.initialize(); // Local persisted release only.
|
|
22
|
+
await client.refresh(); // Explicit network check.
|
|
23
|
+
|
|
24
|
+
<AssetlibImage
|
|
25
|
+
client={client}
|
|
26
|
+
asset={AppAssets.Travel.coast}
|
|
27
|
+
fallback={require('./assets/coast.png')}
|
|
28
|
+
revision={refreshCounter}
|
|
29
|
+
pixelWidth={600}
|
|
30
|
+
pixelHeight={450}
|
|
31
|
+
style={{ width: '100%', aspectRatio: 4 / 3 }}
|
|
32
|
+
contentFit="cover"
|
|
33
|
+
onStatus={(status) => console.log(status.source, status.sequence)}
|
|
34
|
+
/>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Create a long-lived client outside repeated renders. After an explicit `refresh()`, increment `revision` to request the current artwork. `AssetlibImage` initially displays the bundled fallback, then a verified local image. A native/browser image decode error returns to the bundled image. Supply a stable `fallback` value, such as React Native's numeric `require()` result.
|
|
38
|
+
|
|
39
|
+
`onStatus` reports source selection and verification, including the effective `arm` (`null` for control) and `armSource` (`explicit`, `decision`, `control`, or `invalid-decision`). Initial bundle and renderer-failure statuses use `arm: null` and `armSource: 'control'`; resolved statuses preserve the core's decision diagnostics. This callback is not a user impression or proof that the image was visible. Do not send experiment exposure events from this callback. App layout, accessibility labels, image sizing, and refresh policy remain app-owned. Rendering uses `expo-image` for downloaded PNG/WebP support on iOS and Android; props follow Expo Image except `cachePolicy`, which controls Assetlib's verified byte retention. The renderer's independent cache is always disabled. Pending resolution is cancelled when a component unmounts or changes its asset request.
|
|
40
|
+
|
|
41
|
+
## Appearance variants
|
|
42
|
+
|
|
43
|
+
Declare `"variants": { "appearance": ["dark"] }` on a checked-in catalog placement and regenerate its reference to enable a dark cell. `AssetlibImage`, `AssetlibStateImage`, and `AssetlibDynamicImage` accept `appearance="light"`, `"dark"`, or `"system"` (the default). System mode reads React Native's `useColorScheme()`; a null scheme sends no appearance preference. A change in the effective appearance cancels the previous request and resolves again automatically.
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
<AssetlibImage
|
|
47
|
+
client={client}
|
|
48
|
+
asset={AppAssets.Travel.coast}
|
|
49
|
+
fallback={require('./assets/coast.png')}
|
|
50
|
+
fallbackDark={require('./assets/coast-dark.png')}
|
|
51
|
+
appearance="system"
|
|
52
|
+
/>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Published placements resolve the requested appearance cell when bound, or inherit the placement's Any image. Stateful placements select one complete appearance family; individual states never borrow across appearances. `fallbackDark` is optional on image and dynamic-image components. State images accept an optional `fallbacksDark` map containing every declared state. Dark mode uses that complete bundle when supplied; otherwise it uses the existing `fallback` or `fallbacks`. Bundled accessibility metadata remains app-owned: provide descriptions suitable for the displayed fallback.
|
|
56
|
+
|
|
57
|
+
Dynamic collection payloads have no appearance cells, so their verified image stays the same while their bundled placeholder can follow appearance. Animations continue using the Any cell. Appearance-scoped cache keys isolate light, dark, and no-preference image requests, including historical cache fallback.
|
|
58
|
+
|
|
59
|
+
## Tintable icons
|
|
60
|
+
|
|
61
|
+
Declare `"rendering": "template"` on a catalog placement and regenerate its reference. Assetlib then delivers a single-color shape, and your app supplies the color, for example from its theme or a selected state. `AssetlibImage` and `AssetlibStateImage` pass `tintColor` to Expo Image for both the bundled and the downloaded image, so switching between them keeps the same color.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<AssetlibImage
|
|
65
|
+
client={client}
|
|
66
|
+
asset={AppAssets.Tabs.trips}
|
|
67
|
+
fallback={require('./assets/tab-trips.png')}
|
|
68
|
+
tintColor={focused ? colors.ink : colors.muted}
|
|
69
|
+
style={{ width: 24, height: 24 }}
|
|
70
|
+
/>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- TypeScript requires `tintColor` when `asset` is a generated template reference. At runtime, a template reference without `tintColor` logs one development warning per placement key.
|
|
74
|
+
- The bundled fallback must be a single-color shape too, such as a PNG with alpha.
|
|
75
|
+
- Without `pixelWidth` and `pixelHeight`, a template reference requests the placement's logical size × `PixelRatio.get()`, rounded up, so small icons stay sharp. Other references keep requesting the logical size.
|
|
76
|
+
- If the published artwork's rendering does not match the reference, the component keeps the bundled fallback without downloading (see [tintable icons](../sdk-core/README.md#tintable-icons) in the core guide).
|
|
77
|
+
|
|
78
|
+
## Arms and app decisions
|
|
79
|
+
|
|
80
|
+
Declare `"variants": { "arm": ["b", "c"], "appearance": ["dark"] }` on a catalog placement to enable arms alongside appearance. Arms contain 1–4 unique lowercase names matching `^[a-z][a-z0-9_-]{0,19}$`; `control`, `any`, `constructor`, `prototype`, and `__proto__` are reserved. Control remains the placement's existing image.
|
|
81
|
+
|
|
82
|
+
`AssetlibImage` and `AssetlibStateImage` accept an optional `arm="b"` prop. An explicit arm bypasses the decision callback; `arm="control"` selects control. Changing the prop cancels the previous request and resolves again. Without an explicit arm, the client may ask the app's experiment tool for the assignment:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
const client = createExpoAssetClient(parsePublicConfig(publicConfig), {
|
|
86
|
+
decide: async ({ key, arms, appearance }) => {
|
|
87
|
+
// Read an assignment from app-owned state; do not record exposure here.
|
|
88
|
+
return assignments[key];
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
<AssetlibImage
|
|
93
|
+
client={client}
|
|
94
|
+
asset={AppAssets.Travel.coast}
|
|
95
|
+
arm="b"
|
|
96
|
+
appearance="dark"
|
|
97
|
+
fallback={require('./assets/coast.png')}
|
|
98
|
+
/>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The callback receives the placement key, declared arms, and requested appearance. It runs once per resolution, including once for a complete state family, and never for placements without arms. It runs outside the client operation queue with a default deadline of 1,500 ms; configure `decisionTimeoutMs` from 100 to 10,000 ms on the client. Refresh, initialize, and explicit arm requests can proceed while a decision is pending. Afterward, the client revalidates durable state and resolves against the current release. A failed revalidation uses the bundle. An undefined, undeclared, throwing, or timed-out decision falls back to control with `armSource: 'invalid-decision'` and a reason in the status message. With no callback, control is used. If the app's assignment changes, update `arm` or increment `revision` to request it again. **Exposure must be logged by the app's experiment tool when its own visibility rules are met, never by `decide` or `onStatus`.**
|
|
102
|
+
|
|
103
|
+
Resolution checks `(arm, appearance)`, `(arm, Any)`, `(control, appearance)`, then `(control, Any)`. It never borrows another arm's artwork or mixes state families. Status `arm` identifies the actual selected artwork: inheriting control or displaying a bundled fallback reports `null`, even when the request came from an explicit arm or decision. Image caches isolate both requested arm and appearance, including historical fallback. Dynamic collections and animations do not use arm cells. Bundled fallbacks remain control artwork; there are no per-arm bundled props.
|
|
104
|
+
|
|
105
|
+
## Accessibility per usage
|
|
106
|
+
|
|
107
|
+
Choose how each use of an image should behave. Descriptive images can opt into the localized metadata that matches the displayed artwork:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<AssetlibImage
|
|
111
|
+
client={client}
|
|
112
|
+
asset={AppAssets.Travel.coast}
|
|
113
|
+
fallback={require('./assets/coast.png')}
|
|
114
|
+
accessibilityMode="description"
|
|
115
|
+
accessibilityLocale="en-GB"
|
|
116
|
+
fallbackAccessibility={{
|
|
117
|
+
defaultLocale: 'en',
|
|
118
|
+
descriptions: { en: 'An illustrated coastal escape' },
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`fallbackAccessibility` describes only the bundled image. It defaults to the reference's `bundledAccessibility` when supplied by the checked-in catalog. Remote and cached artwork use their own signed descriptions; after a decode failure or request change, both the image and description return to the corresponding bundle. Description mode keeps the bundle if a remote image has no description. Native `accessibilityLabel` remains an explicit app override, including for legacy releases without metadata. Supply a useful bundled description when choosing description mode.
|
|
124
|
+
|
|
125
|
+
For decorative artwork, set `accessibilityMode="decorative"`; the image is hidden from accessibility services. If an image is inside a control, label the action on the control and make the artwork decorative. Omitting the mode preserves ordinary Expo accessibility props and does not attach a universal description. Use the app's active locale with `accessibilityLocale`; lookup tries exact locale, parent subtags, then the metadata's explicit default.
|
|
126
|
+
|
|
127
|
+
`AssetlibDynamicImage` accepts the same props and an explicit `fallbackAccessibility` for its placeholder. `AssetlibStateImage` takes a `fallbackAccessibility` map keyed by state (or the catalog reference's `bundledStateAccessibility`); state selection switches image and description together. In description mode, if any state lacks remote metadata, the entire family stays bundled. The pure `resolveAccessibilityDescription()` helper and `AssetAccessibility` type are exported for custom renderers. These APIs do not generate descriptions or label raw images automatically.
|
|
128
|
+
|
|
129
|
+
## Artwork states in one component
|
|
130
|
+
|
|
131
|
+
Use a state set for artwork that belongs to a single placement, such as a plant progressing from sprout to full growth. The application chooses the state from its own business logic. Every declared state needs a bundled fallback.
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { AssetlibStateImage, type StateSetRef } from '@assetlib/sdk-expo';
|
|
135
|
+
|
|
136
|
+
const garden = {
|
|
137
|
+
key: 'tasks.garden', width: 120, height: 120,
|
|
138
|
+
states: ['empty', 'sprout', 'growing', 'complete'],
|
|
139
|
+
} as const satisfies StateSetRef;
|
|
140
|
+
|
|
141
|
+
<AssetlibStateImage
|
|
142
|
+
client={client}
|
|
143
|
+
asset={garden}
|
|
144
|
+
state={garden.states[completedCount]}
|
|
145
|
+
fallbacks={{
|
|
146
|
+
empty: require('./assets/garden-empty.png'),
|
|
147
|
+
sprout: require('./assets/garden-sprout.png'),
|
|
148
|
+
growing: require('./assets/garden-growing.png'),
|
|
149
|
+
complete: require('./assets/garden-complete.png'),
|
|
150
|
+
}}
|
|
151
|
+
style={{ width: 120, height: 120 }}
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The component prepares the complete verified family before showing it. Changing only `state` selects from the pinned family without downloading again. A changed client, state-set contract, `revision`, pixel target, cache policy, arm, or effective appearance starts a new resolution. If preparing or displaying a member fails, the entire family uses its matching bundled states until the next resolution. It never substitutes another growth state for a missing one. App code is responsible for supplying a valid declared state.
|
|
156
|
+
|
|
157
|
+
## Dynamic collections and retention
|
|
158
|
+
|
|
159
|
+
Use `AssetlibDynamicImage` with an opaque runtime reference issued by this client's verified `loadAssetPage()` result. An arbitrary image URL or hand-built object is not a trusted runtime reference. It resolves on mount and cancels on unmount or reference change; use the application's list virtualization to load only visible or deliberately prefetched items.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
<AssetlibDynamicImage
|
|
163
|
+
client={client}
|
|
164
|
+
asset={verifiedRuntimeRef}
|
|
165
|
+
fallback={require('./assets/destination-placeholder.png')}
|
|
166
|
+
cachePolicy="none"
|
|
167
|
+
pixelWidth={600}
|
|
168
|
+
pixelHeight={450}
|
|
169
|
+
style={{ width: '100%', aspectRatio: 4 / 3 }}
|
|
170
|
+
/>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Set `cachePolicy` on the client as a default, or override it on any of the three image components:
|
|
174
|
+
|
|
175
|
+
| Policy | Verified image retention |
|
|
176
|
+
| --- | --- |
|
|
177
|
+
| `disk` (default) | Bounded persistent filesystem or IndexedDB cache. |
|
|
178
|
+
| `memory` | Bounded cache for the client's lifetime; no persistent artwork reads or writes. |
|
|
179
|
+
| `none` | No reusable byte cache; the currently rendered image or pinned state set still needs memory. |
|
|
180
|
+
|
|
181
|
+
For example, `createExpoAssetClient(config, { cachePolicy: 'memory' })` keeps artwork ephemeral while preserving durable signed release metadata and replay protection. Native `memory`/`none` images use verified raster data URIs rather than image files; web images use ephemeral Blob URLs. Changing policy does not delete artwork saved by an earlier disk-caching request. No automatic reset or purge of app data is performed. Load timing and retention are independent: using `disk` does not download the entire library in advance.
|
|
182
|
+
|
|
183
|
+
## Storage and networking
|
|
184
|
+
|
|
185
|
+
Native uses the maintained Expo `File`, `Directory`, and `Paths` APIs for durable verification metadata and optional disk artwork, with private app-document storage and a recoverable state replacement journal. Native delivery uses streaming `expo/fetch`. Web uses transactional IndexedDB for durable metadata and optional cached artwork, browser Fetch, and ephemeral Blob image URLs, released when the component changes or unmounts. No credentials are attached to public delivery requests; the server must allow the web demo origin through CORS.
|
|
186
|
+
|
|
187
|
+
Caches and durable replay state are isolated by manifest origin, organization, app, and environment. Changing pins or switching between the legacy and environment-scoped production manifest routes preserves that namespace. On first use, an empty namespace checks both previous URL/key formulas (single-key and sorted-key-set), verifies the complete stored history with the current pins, and moves valid state and bounded cached artwork into the new namespace. Invalid legacy state is left untouched. A persisted completion marker prevents re-migration; missing or corrupt current state fails closed. Persistent artwork storage, including cached Lottie JSON and MP4 clips, is bounded to 50 MiB / 100 entries per namespace; the core retains eight signed releases. Cached content is rehashed. When verified artwork cannot be fetched or found in an eligible cache, the component uses its bundled fallback.
|
|
188
|
+
|
|
189
|
+
For the first upgrade from the old namespace scheme, retain the previous pinned set until migration has completed. Migration recognizes subsets of the current pins, including an expansion during upgrade, but cannot reconstruct an old sorted-set namespace containing a removed, unused pin from its one-way hash. Searches are limited to 4,096 namespace probes; exceeding this limit fails closed with bundled artwork instead of resetting replay protection. After migration, pin changes do not change storage identity; every retained release must still verify with the current pins.
|
|
190
|
+
|
|
191
|
+
There is no automatic app-data reset API: disconnect the client to show bundled artwork while retaining the cache. Explicitly clearing app/browser data removes stored replay protection. Multiple native clients are serialized within one JavaScript process; cross-process synchronization is outside this preview's contract. Preview 0.2 supports PNG/WebP renditions selected by explicit pixel size. Logical placement dimensions still match the generated catalog. Native caches preserve compatibility with existing `.webp` entries. Web can opt into normalized SVG with `createExpoAssetClient(config, { allowVector: true })`; enabling that option on native throws. On native, `client.resolveAnimation(ref)` resolves verified Lottie JSON and, under the `disk` policy, caches it as `<sha256>.json` beside the cached images (at most 512 KiB per entry, inside the same budget) for a Lottie player that the app supplies. The SDK includes no player, and its image components show the still poster. Native SVG and PDF delivery are not implemented. Browser rendering checks decoded dimensions before handing the image to Expo; native Expo decode errors fall back to the bundled image. See the core README for signature, timeout, body-size, and compatibility limits.
|
|
192
|
+
|
|
193
|
+
## Short looping video
|
|
194
|
+
|
|
195
|
+
`AssetlibVideo` shows a placement's poster and plays its signed short looping MP4 clip over it. It ships from a separate entry point so `expo-video` stays an optional peer dependency; importing `@assetlib/sdk-expo` never loads it. **This has not been verified on a device, simulator, emulator or browser**; it is covered by Node unit tests with a simulated player only.
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
npx expo install expo-video
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import { AssetlibVideo } from '@assetlib/sdk-expo/video';
|
|
203
|
+
|
|
204
|
+
<AssetlibVideo
|
|
205
|
+
client={client}
|
|
206
|
+
asset={AppAssets.Cards.gold}
|
|
207
|
+
fallback={require('./assets/gold.png')}
|
|
208
|
+
revision={refreshCounter}
|
|
209
|
+
contentFit="cover"
|
|
210
|
+
style={{ width: '100%', aspectRatio: 4 / 3 }}
|
|
211
|
+
/>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **Poster first.** It takes `AssetlibImage`'s props and renders that image as the poster, bundled fallback first. `style` sizes the container; the poster and clip fill it. The poster stays visible until the clip's first frame renders and returns on any failure.
|
|
215
|
+
- **Playback.** `expo-video`'s `useVideoPlayer` with `loop` and `muted` (screen not kept awake), in a `VideoView` with `nativeControls={false}` and the poster's `contentFit` (`none` and `scale-down` become `contain`).
|
|
216
|
+
- **When it plays.** Only while `AccessibilityInfo.isReduceMotionEnabled()` reports `false` (it listens to `reduceMotionChanged`; an unknown or failing query counts as reduced), the app is active (`AppState`; `background` and `inactive` stop it), `paused` is not set, and the view holds one of two playback slots shared by every `AssetlibVideo` in the app. Other views show their poster and take a slot when one frees. A view resolves its clip only once it holds a slot. Off-screen detection is up to the app: pass `paused` for views that are not visible.
|
|
217
|
+
- **Failures.** A missing clip, a failed check, a player error, or a rejected web `play()` (autoplay blocked, for example in iOS Low Power Mode) shows the poster until the request changes (client, asset, `revision` or `cachePolicy`).
|
|
218
|
+
- **iOS and Android.** The native adapter caches verified clips as `<sha256>.mp4` in the namespaced cache directory (at most 4 MiB each, inside the 50 MiB / 100 entry budget) and the core returns the file's `file://` URI. Under the `memory` and `none` policies `resolveVideo` returns the poster without downloading, because the player needs a file.
|
|
219
|
+
- **Web.** Verified bytes from IndexedDB, memory or the network become a `video/mp4` Blob URL, revoked when the clip changes or the component unmounts.
|
|
220
|
+
- **Open risk.** `expo-video` documents `require()` assets and media-library URIs as local sources, not plain `file://` URIs. Its 57.0.5 source passes a string URI through unchanged: iOS builds an `AVURLAsset` from it (only `ph://` is special-cased) and Android hands it to Media3's `DefaultDataSource`, which reads `file://` URIs. Whether a cached file plays on devices is unverified.
|
|
221
|
+
- Limits and the signed descriptor are in the core README's [Short looping video](../sdk-core/README.md#short-looping-video) section. `createExpoAssetClient(config, { videoTimeoutMs })` sets the clip deadline.
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
Telemetry is **off by default**. Enable it explicitly to report which published placement artwork the app resolves and attaches to an image renderer:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const client = createExpoAssetClient(publicConfig, {
|
|
228
|
+
telemetry: {
|
|
229
|
+
enabled: true,
|
|
230
|
+
build: { platform: 'ios', appVersion: '1.6.0', buildNumber: '231' },
|
|
231
|
+
flushIntervalMs: 60_000,
|
|
232
|
+
maxBatch: 200,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
// Optional before a controlled shutdown:
|
|
236
|
+
await client.flush();
|
|
237
|
+
client.dispose(); // Remove the AppState listener and stop observation timers.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The adapter identifies itself as `sdk-expo` version `0.6.0-preview.1`. An explicit `telemetry.sdk` or `telemetry.build` takes precedence. When the runtime can optionally require `expo-application` or `expo-constants`, the adapter reads native app version and build number (falling back to Expo config version/build). Missing packages, unavailable runtime loaders, and throwing native bridges are ignored; no new dependency is required. Supply `build` explicitly for reliable production attribution, especially with bundlers that cannot resolve optional runtime requires or in Expo Go. Without complete runtime metadata or an explicit build, batches use platform `expo` and version/build `unknown`.
|
|
241
|
+
|
|
242
|
+
Only the observation contract fields are sent: environment, SDK name/version, build platform/version/number, random install ID, send time, and coalesced events with placement key, kind/count, source, asset ID/release sequence when known, effective arm/appearance when selected, and fallback reason. Bundle outcomes send only `fallback` events without asset ID or sequence. Dynamic catalog references do not emit observations because they have no placement key. Image bytes, descriptions, URLs, device identifiers, account identifiers, and status/error messages are not included.
|
|
243
|
+
|
|
244
|
+
`AssetlibImage`, `AssetlibStateImage`, and `AssetlibDynamicImage` forward renderer load/error signals to the core (the core ignores dynamic references). For eligible placement images, `display` is counted once per mounted component when a remote or cached image's `onLoad` fires, including after a prop or state change if nothing was loaded earlier. **Display means decoded and attached, not seen by a person.** It is not an experiment exposure or a unique viewer. A current verified image's renderer error reports a `decode` fallback and restores bundled artwork; stale renderer events and bundled-image loads/errors do not add observations. App `onLoad` and `onError` callbacks still run. For a state family, display and decode reports identify the selected state's asset, and changing state does not count another display within that mount.
|
|
245
|
+
|
|
246
|
+
Events are held in bounded memory and coalesced by coordinate. Automatic flush runs while events are queued, on `refresh()`, and when React Native `AppState` reports `background` when available. Each request has a five-second timeout; failed batches remain queued for the next flush. Background execution is best effort and is not a delivery guarantee. `flush()` sends one bounded batch; `dispose()` drops pending events after stopping the reporter, so await a flush first when needed. Observation failures never fail image resolution.
|
|
247
|
+
|
|
248
|
+
The generated ID is a random 32-character hexadecimal string persisted in dedicated metadata, independent of artwork cache policy. Native Expo uses its secure UUID generator when Web Crypto is unavailable. It is per installation and per delivery origin/organization/app, shared across environments and signing-key rotations. It is never derived from a device identifier. Clearing app/browser data resets it (and also removes release verification state). An app may instead supply an opaque `telemetry.installId` of 8–64 characters; never pass an account, advertising, or hardware identifier.
|
|
249
|
+
|
|
250
|
+
Enabling telemetry changes the app's data collection behavior. Review Google Play Data safety, Apple App Privacy disclosures, your privacy policy, consent requirements, and any applicable iOS privacy manifest declarations for the app's actual use. These SDK options do not configure or satisfy those declarations automatically. The hosted service stores daily aggregates for 90 days and daily install hashes for 7 days, rather than raw events. See [optional runtime observations](../sdk-core/README.md#optional-runtime-observations) in the core guide for the complete fields, retention, and suggested Data safety wording. Install counts reflect reporting installations, not unique people or all app users.
|
|
251
|
+
|
|
252
|
+
## Validation
|
|
253
|
+
|
|
254
|
+
`npm test` exercises component lifecycle behavior with a React test renderer and mocked platform boundaries, including state-family activation, pinned selection, cancellation, fallback, native no-file data URI generation, and `AssetlibVideo`'s reduced-motion, app-state, two-slot and failure handling against a simulated player. `npm run typecheck` checks against the current Expo 57 APIs. Core security and offline tests run in the sibling package. Actual iOS/Android runtime and web delivery checks must be reported separately; these tests and typechecking are not evidence of a native device run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assetlib/sdk-expo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.0-preview.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.tsx",
|
|
7
|
+
"types": "./src/index.tsx",
|
|
8
|
+
"react-native": "./src/index.tsx",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.tsx",
|
|
11
|
+
"./video": "./src/video.tsx"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "node --test test/*.test.mjs"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@assetlib/sdk-core": "0.6.0-preview.1"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"expo": ">=57 <58",
|
|
27
|
+
"expo-file-system": ">=57 <58",
|
|
28
|
+
"expo-image": ">=57 <58",
|
|
29
|
+
"expo-video": ">=57 <58",
|
|
30
|
+
"react": ">=19 <20",
|
|
31
|
+
"react-native": ">=0.86 <0.87"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"expo-video": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "^19.2.0",
|
|
40
|
+
"react": "19.2.3",
|
|
41
|
+
"react-test-renderer": "19.2.3",
|
|
42
|
+
"typescript": "5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/AssetLib/sdk-js.git",
|
|
50
|
+
"directory": "packages/sdk-expo"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { PixelRatio, useColorScheme } from 'react-native';
|
|
3
|
+
import { Image, type ImageProps } from 'expo-image';
|
|
4
|
+
import { createAssetClient, parsePublicConfig, resolveAccessibilityDescription, type AssetAccessibility, type AssetClient, type AssetlibConfig, type AssetRef, type AssetStatus, type CachePolicy, type ClientOptions, type DynamicAssetRef, type ResolvedAsset, type StateSetRef } from '@assetlib/sdk-core';
|
|
5
|
+
import { createPlatformStorage, imageUri, platformFetch, vectorRenderingSupported } from './platform';
|
|
6
|
+
import type { ImageUri } from './shared';
|
|
7
|
+
import { expoTelemetry, flushOnBackground } from './telemetry';
|
|
8
|
+
export { resolveAccessibilityDescription } from '@assetlib/sdk-core';
|
|
9
|
+
export type { AssetAccessibility, AssetClient, AssetlibConfig, AssetRef, AssetRendering, AssetStatus, CachePolicy, ClientStatus, DynamicAssetRef, RefreshResult, ResolvedVideo, StateSetRef, TelemetryOptions } from '@assetlib/sdk-core';
|
|
10
|
+
|
|
11
|
+
export function createExpoAssetClient(config: AssetlibConfig, options: { allowInsecureLoopback?: boolean; timeoutMs?: number; videoTimeoutMs?: number; decisionTimeoutMs?: number; allowVector?: boolean; cachePolicy?: CachePolicy; decide?: ClientOptions['decide']; telemetry?: ClientOptions['telemetry'] } = {}): AssetClient {
|
|
12
|
+
const validated = parsePublicConfig(config, options);
|
|
13
|
+
if (options.allowVector && !vectorRenderingSupported) throw new Error('SVG delivery is supported only by the browser adapter.');
|
|
14
|
+
const telemetry = expoTelemetry(options.telemetry);
|
|
15
|
+
const client = createAssetClient(validated, { ...options, telemetry, formats: options.allowVector ? ['image/webp', 'image/png', 'image/svg+xml'] : ['image/webp', 'image/png'], storage: createPlatformStorage(validated), fetch: platformFetch });
|
|
16
|
+
if (telemetry?.enabled) flushOnBackground(client);
|
|
17
|
+
return client;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ImageSource = NonNullable<ImageProps['source']>;
|
|
21
|
+
type DeliveryImageProps = Omit<ImageProps, 'source' | 'cachePolicy'> & {
|
|
22
|
+
client: AssetClient;
|
|
23
|
+
revision?: number;
|
|
24
|
+
pixelWidth?: number;
|
|
25
|
+
pixelHeight?: number;
|
|
26
|
+
/** Assetlib retention policy. The renderer's independent cache stays disabled. */
|
|
27
|
+
cachePolicy?: CachePolicy;
|
|
28
|
+
/** Follow the system appearance by default; an unknown system appearance means no preference. */
|
|
29
|
+
appearance?: 'light' | 'dark' | 'system';
|
|
30
|
+
/** Opt in per usage. Native accessibilityLabel remains an explicit app override. */
|
|
31
|
+
accessibilityMode?: 'description' | 'decorative';
|
|
32
|
+
accessibilityLocale?: string;
|
|
33
|
+
onStatus?: (status: AssetStatus) => void;
|
|
34
|
+
};
|
|
35
|
+
/** A tintable icon reference requires the app's tint color for its remote and bundled artwork. */
|
|
36
|
+
type TemplateTint<R> = R extends { readonly rendering: 'template' } ? { tintColor: string } : unknown;
|
|
37
|
+
export type AssetlibImageProps<R extends AssetRef = AssetRef> = DeliveryImageProps & { asset: R; arm?: string; fallback: ImageSource; fallbackDark?: ImageSource; fallbackAccessibility?: AssetAccessibility } & TemplateTint<R>;
|
|
38
|
+
export type AssetlibDynamicImageProps = DeliveryImageProps & { asset: DynamicAssetRef; fallback: ImageSource; fallbackDark?: ImageSource; fallbackAccessibility?: AssetAccessibility };
|
|
39
|
+
export type AssetlibStateImageProps<R extends StateSetRef = StateSetRef> = DeliveryImageProps & { asset: R; arm?: string; state: string; fallbacks: Readonly<Record<string, ImageSource>>; fallbacksDark?: Readonly<Record<string, ImageSource>>; fallbackAccessibility?: Readonly<Record<string, AssetAccessibility>> } & TemplateTint<R>;
|
|
40
|
+
|
|
41
|
+
function useEffectiveAppearance(appearance: 'light' | 'dark' | 'system') {
|
|
42
|
+
const systemAppearance = useColorScheme();
|
|
43
|
+
if (appearance !== 'system') return appearance;
|
|
44
|
+
return systemAppearance === 'light' || systemAppearance === 'dark' ? systemAppearance : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Template masks are rasters: without an explicit target, request logical size × display scale. */
|
|
48
|
+
function pixelTarget(asset: AssetRef, pixelWidth?: number, pixelHeight?: number): { pixelWidth?: number; pixelHeight?: number } {
|
|
49
|
+
if (asset.rendering !== 'template' || pixelWidth !== undefined || pixelHeight !== undefined) return { pixelWidth, pixelHeight };
|
|
50
|
+
const ratio = PixelRatio.get();
|
|
51
|
+
const scale = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
52
|
+
return { pixelWidth: Math.min(8192, Math.ceil(asset.width * scale)), pixelHeight: Math.min(8192, Math.ceil(asset.height * scale)) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tintWarnings = new Set<string>();
|
|
56
|
+
function useTemplateTintWarning(asset: AssetRef, tintColor: unknown) {
|
|
57
|
+
const missing = asset.rendering === 'template' && tintColor == null;
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!missing || typeof __DEV__ === 'undefined' || !__DEV__ || tintWarnings.has(asset.key)) return;
|
|
60
|
+
tintWarnings.add(asset.key);
|
|
61
|
+
console.warn(`Assetlib: ${asset.key} is a tintable icon. Pass tintColor so its remote and bundled artwork are drawn in your app's color.`);
|
|
62
|
+
}, [missing, asset.key]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bundled = (message: string): AssetStatus => ({ source: 'bundle', sequence: null, message, arm: null, armSource: 'control' });
|
|
66
|
+
function assetStatus(result: AssetStatus): AssetStatus {
|
|
67
|
+
return { source: result.source, sequence: result.sequence, message: result.message, arm: result.arm, armSource: result.armSource, ...(result.appearance ? { appearance: result.appearance } : {}), ...(result.sha256 ? { sha256: result.sha256 } : {}), ...(result.assetId ? { assetId: result.assetId } : {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function accessibilityProps(props: ImageProps, mode: DeliveryImageProps['accessibilityMode'], metadata?: AssetAccessibility, locale?: string): Partial<ImageProps> {
|
|
71
|
+
if (mode === 'decorative') return { accessible: false, accessibilityLabel: undefined, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants', 'aria-hidden': true };
|
|
72
|
+
if (mode !== 'description') return {};
|
|
73
|
+
const label = props.accessibilityLabel ?? resolveAccessibilityDescription(metadata, locale);
|
|
74
|
+
return { accessibilityLabel: label, accessible: props.accessible ?? !!label };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Expo dispatches native events through current props; remount on source changes. */
|
|
78
|
+
function useRendererKey(request: object, source: unknown, state?: string): number {
|
|
79
|
+
const generation = useRef(0);
|
|
80
|
+
return useMemo(() => ++generation.current, [request, source, state]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchesLoadedSource(event: Parameters<NonNullable<ImageProps['onLoad']>>[0], uri: string): boolean {
|
|
84
|
+
return event?.source?.url === undefined || event.source.url === uri;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A request token hides the previous asset immediately when a recycled view changes identity. */
|
|
88
|
+
function useResolvedImage(client: AssetClient, asset: AssetRef | DynamicAssetRef, request: object, resolve: (signal: AbortSignal) => Promise<ResolvedAsset>, fallback: ImageSource, fallbackAccessibility: AssetAccessibility | undefined, requireDescription: boolean, onStatus?: (status: AssetStatus) => void) {
|
|
89
|
+
const [resolved, setResolved] = useState<{ request: object; image: ImageUri; result: ResolvedAsset; accessibility?: AssetAccessibility } | null>(null);
|
|
90
|
+
const callbacks = useRef({ resolve, onStatus });
|
|
91
|
+
callbacks.current = { resolve, onStatus };
|
|
92
|
+
const currentUri = useRef<ImageUri | null>(null);
|
|
93
|
+
const currentRequest = useRef(request);
|
|
94
|
+
currentRequest.current = request;
|
|
95
|
+
const displayed = useRef(false);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
let localUri: ImageUri | null = null;
|
|
99
|
+
setResolved(null);
|
|
100
|
+
callbacks.current.onStatus?.(bundled('Bundled artwork is ready while the image resolves.'));
|
|
101
|
+
callbacks.current.resolve(controller.signal).then(async result => {
|
|
102
|
+
if (controller.signal.aborted) return;
|
|
103
|
+
if (result.bytes) {
|
|
104
|
+
if (requireDescription && !result.accessibility) {
|
|
105
|
+
callbacks.current.onStatus?.(bundled('Using bundled artwork because the verified image has no accessibility description.'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
localUri = await imageUri(client.config, result);
|
|
109
|
+
if (controller.signal.aborted) { localUri.release(); return; }
|
|
110
|
+
currentUri.current = localUri;
|
|
111
|
+
setResolved({ request, image: localUri, result, accessibility: result.accessibility });
|
|
112
|
+
}
|
|
113
|
+
callbacks.current.onStatus?.(assetStatus(result));
|
|
114
|
+
}).catch(() => {
|
|
115
|
+
if (controller.signal.aborted) return;
|
|
116
|
+
setResolved(null);
|
|
117
|
+
callbacks.current.onStatus?.(bundled('Using bundled artwork because the verified image could not be loaded.'));
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
controller.abort();
|
|
121
|
+
localUri?.release();
|
|
122
|
+
if (currentUri.current === localUri) currentUri.current = null;
|
|
123
|
+
};
|
|
124
|
+
}, [client, request]);
|
|
125
|
+
const isCurrent = () => resolved?.request === request && currentRequest.current === request && currentUri.current === resolved.image;
|
|
126
|
+
const selected = resolved?.request === request ? resolved : null;
|
|
127
|
+
const rendererKey = useRendererKey(request, selected?.image ?? fallback);
|
|
128
|
+
return {
|
|
129
|
+
rendererKey,
|
|
130
|
+
source: resolved?.request === request ? { uri: resolved.image.uri } : fallback,
|
|
131
|
+
accessibility: resolved?.request === request ? resolved.accessibility : fallbackAccessibility,
|
|
132
|
+
loaded(event: Parameters<NonNullable<ImageProps['onLoad']>>[0]) {
|
|
133
|
+
if (!isCurrent() || displayed.current || resolved!.result.source === 'bundle' || !matchesLoadedSource(event, resolved!.image.uri)) return;
|
|
134
|
+
displayed.current = true;
|
|
135
|
+
client.reportDisplay(asset, resolved!.result);
|
|
136
|
+
},
|
|
137
|
+
fail() {
|
|
138
|
+
if (!isCurrent()) return;
|
|
139
|
+
client.reportFallback(asset, resolved!.result, 'decode');
|
|
140
|
+
setResolved(null);
|
|
141
|
+
currentUri.current?.release(); currentUri.current = null;
|
|
142
|
+
callbacks.current.onStatus?.(bundled('The image could not be displayed. Using bundled artwork.'));
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function AssetlibImage<R extends AssetRef>({ client, asset, arm, fallback, fallbackDark, appearance = 'system', fallbackAccessibility = asset.bundledAccessibility, accessibilityMode, accessibilityLocale, revision = 0, pixelWidth: requestedPixelWidth, pixelHeight: requestedPixelHeight, cachePolicy, onStatus, onError, onLoad, ...rest }: AssetlibImageProps<R>) {
|
|
148
|
+
const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
|
|
149
|
+
const effectiveAppearance = useEffectiveAppearance(appearance);
|
|
150
|
+
const { pixelWidth, pixelHeight } = pixelTarget(asset, requestedPixelWidth, requestedPixelHeight);
|
|
151
|
+
useTemplateTintWarning(asset, props.tintColor);
|
|
152
|
+
const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
|
|
153
|
+
const request = useMemo(() => ({}), [client, asset.key, asset.width, asset.height, asset.rendering, arm, revision, pixelWidth, pixelHeight, cachePolicy, requireDescription, effectiveAppearance]);
|
|
154
|
+
const image = useResolvedImage(client, asset, request, signal => client.resolve(asset, { pixelWidth, pixelHeight, cachePolicy, appearance: effectiveAppearance, arm, signal }), effectiveAppearance === 'dark' ? fallbackDark ?? fallback : fallback, fallbackAccessibility, requireDescription, onStatus);
|
|
155
|
+
return <Image {...props} key={image.rendererKey} {...accessibilityProps(props, accessibilityMode, image.accessibility, accessibilityLocale)} cachePolicy="none" source={image.source} onLoad={event => { image.loaded(event); onLoad?.(event); }} onError={event => { image.fail(); onError?.(event); }} />;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Runtime references come from this client's verified collection page, never from an arbitrary URL. */
|
|
159
|
+
export function AssetlibDynamicImage({ client, asset, fallback, fallbackDark, appearance = 'system', fallbackAccessibility, accessibilityMode, accessibilityLocale, revision = 0, pixelWidth, pixelHeight, cachePolicy, onStatus, onError, onLoad, ...props }: AssetlibDynamicImageProps) {
|
|
160
|
+
const effectiveAppearance = useEffectiveAppearance(appearance);
|
|
161
|
+
const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
|
|
162
|
+
const request = useMemo(() => ({}), [client, asset, revision, pixelWidth, pixelHeight, cachePolicy, requireDescription, effectiveAppearance]);
|
|
163
|
+
const image = useResolvedImage(client, asset, request, signal => client.resolveAsset(asset, { pixelWidth, pixelHeight, cachePolicy, appearance: effectiveAppearance, signal }), effectiveAppearance === 'dark' ? fallbackDark ?? fallback : fallback, fallbackAccessibility, requireDescription, onStatus);
|
|
164
|
+
return <Image {...props} key={image.rendererKey} {...accessibilityProps(props, accessibilityMode, image.accessibility, accessibilityLocale)} recyclingKey={props.recyclingKey ?? `${asset.assetId}:${asset.sequence}`} cachePolicy="none" source={image.source} onLoad={event => { image.loaded(event); onLoad?.(event); }} onError={event => { image.fail(); onError?.(event); }} />;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Resolves a complete visual family once. Changing only `state` selects the pinned result. */
|
|
168
|
+
export function AssetlibStateImage<R extends StateSetRef>({ client, asset, arm, state, fallbacks, fallbacksDark, appearance = 'system', fallbackAccessibility = asset.bundledStateAccessibility, accessibilityMode, accessibilityLocale, revision = 0, pixelWidth: requestedPixelWidth, pixelHeight: requestedPixelHeight, cachePolicy, onStatus, onError, onLoad, ...rest }: AssetlibStateImageProps<R>) {
|
|
169
|
+
const props: Omit<ImageProps, 'source' | 'cachePolicy'> = rest;
|
|
170
|
+
const effectiveAppearance = useEffectiveAppearance(appearance);
|
|
171
|
+
const { pixelWidth, pixelHeight } = pixelTarget(asset, requestedPixelWidth, requestedPixelHeight);
|
|
172
|
+
useTemplateTintWarning(asset, props.tintColor);
|
|
173
|
+
const statesKey = JSON.stringify(asset.states);
|
|
174
|
+
const requireDescription = accessibilityMode === 'description' && props.accessibilityLabel == null;
|
|
175
|
+
const request = useMemo(() => ({}), [client, asset.key, asset.width, asset.height, asset.rendering, statesKey, arm, revision, pixelWidth, pixelHeight, cachePolicy, requireDescription, effectiveAppearance]);
|
|
176
|
+
const [resolved, setResolved] = useState<{ request: object; images: Record<string, ImageUri>; results: Readonly<Record<string, ResolvedAsset>>; accessibility: Record<string, AssetAccessibility | undefined> } | null>(null);
|
|
177
|
+
const callbacks = useRef({ onStatus });
|
|
178
|
+
callbacks.current = { onStatus };
|
|
179
|
+
const currentUris = useRef<Record<string, ImageUri> | null>(null);
|
|
180
|
+
const currentSelection = useRef({ request, state });
|
|
181
|
+
currentSelection.current = { request, state };
|
|
182
|
+
const displayed = useRef(false);
|
|
183
|
+
if (!asset.states.includes(state)) throw new Error(`Unknown artwork state: ${state}.`);
|
|
184
|
+
if (asset.states.some(name => !Object.prototype.hasOwnProperty.call(fallbacks, name) || fallbacks[name] == null)) throw new Error('Every artwork state requires its own bundled fallback.');
|
|
185
|
+
if (fallbacksDark && asset.states.some(name => !Object.prototype.hasOwnProperty.call(fallbacksDark, name) || fallbacksDark[name] == null)) throw new Error('Every artwork state requires its own dark bundled fallback when fallbacksDark is supplied.');
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const images: Record<string, ImageUri> = Object.create(null);
|
|
189
|
+
const accessibility: Record<string, AssetAccessibility | undefined> = Object.create(null);
|
|
190
|
+
const release = () => { for (const image of Object.values(images)) image.release(); };
|
|
191
|
+
setResolved(null);
|
|
192
|
+
callbacks.current.onStatus?.(bundled('Bundled states are ready while the complete artwork set resolves.'));
|
|
193
|
+
client.resolveStateSet(asset, { pixelWidth, pixelHeight, cachePolicy, appearance: effectiveAppearance, arm, signal: controller.signal }).then(async result => {
|
|
194
|
+
if (controller.signal.aborted) return;
|
|
195
|
+
if (result.source === 'bundle') {
|
|
196
|
+
callbacks.current.onStatus?.(assetStatus(result));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (requireDescription && asset.states.some(name => !result.states[name]?.accessibility)) {
|
|
200
|
+
callbacks.current.onStatus?.(bundled('Using bundled states because a verified state has no accessibility description.'));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Commit only after every state can be handed to the renderer.
|
|
204
|
+
for (const name of asset.states) {
|
|
205
|
+
if (!result.states[name]?.bytes) throw new Error('The verified state set is incomplete.');
|
|
206
|
+
images[name] = await imageUri(client.config, result.states[name]);
|
|
207
|
+
accessibility[name] = result.states[name].accessibility;
|
|
208
|
+
if (controller.signal.aborted) { release(); return; }
|
|
209
|
+
}
|
|
210
|
+
currentUris.current = images;
|
|
211
|
+
setResolved({ request, images, results: result.states, accessibility });
|
|
212
|
+
callbacks.current.onStatus?.(assetStatus(result));
|
|
213
|
+
}).catch(() => {
|
|
214
|
+
release();
|
|
215
|
+
if (controller.signal.aborted) return;
|
|
216
|
+
setResolved(null);
|
|
217
|
+
callbacks.current.onStatus?.(bundled('Using the complete bundled state set because verified artwork could not be loaded.'));
|
|
218
|
+
});
|
|
219
|
+
return () => {
|
|
220
|
+
controller.abort(); release();
|
|
221
|
+
if (currentUris.current === images) currentUris.current = null;
|
|
222
|
+
};
|
|
223
|
+
}, [client, request]);
|
|
224
|
+
const selected = resolved?.request === request ? resolved.images[state] : null;
|
|
225
|
+
const metadata = selected ? resolved!.accessibility[state] : fallbackAccessibility?.[state];
|
|
226
|
+
const fallback = (effectiveAppearance === 'dark' ? fallbacksDark ?? fallbacks : fallbacks)[state];
|
|
227
|
+
const rendererKey = useRendererKey(request, selected ?? fallback, state);
|
|
228
|
+
const isCurrent = () => selected && currentSelection.current.request === request && currentSelection.current.state === state && currentUris.current === resolved?.images;
|
|
229
|
+
return <Image {...props} key={rendererKey} {...accessibilityProps(props, accessibilityMode, metadata, accessibilityLocale)} cachePolicy="none" source={selected ? { uri: selected.uri } : fallback} onLoad={event => {
|
|
230
|
+
if (isCurrent() && !displayed.current && resolved!.results[state].source !== 'bundle' && matchesLoadedSource(event, selected!.uri)) {
|
|
231
|
+
displayed.current = true;
|
|
232
|
+
client.reportDisplay(asset, resolved!.results[state]);
|
|
233
|
+
}
|
|
234
|
+
onLoad?.(event);
|
|
235
|
+
}} onError={event => {
|
|
236
|
+
if (isCurrent()) {
|
|
237
|
+
client.reportFallback(asset, resolved!.results[state], 'decode');
|
|
238
|
+
// A decode failure returns the entire family to bundled artwork for this session.
|
|
239
|
+
setResolved(null);
|
|
240
|
+
if (currentUris.current) for (const image of Object.values(currentUris.current)) image.release();
|
|
241
|
+
currentUris.current = null;
|
|
242
|
+
callbacks.current.onStatus?.(bundled('A state image could not be displayed. Using the complete bundled state set.'));
|
|
243
|
+
}
|
|
244
|
+
onError?.(event);
|
|
245
|
+
}} />;
|
|
246
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Directory, File, Paths } from 'expo-file-system';
|
|
2
|
+
import { fetch as expoFetch } from 'expo/fetch';
|
|
3
|
+
import { LOTTIE_LIMITS, SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset, type ResolvedVideo } from '@assetlib/sdk-core';
|
|
4
|
+
import { namespace, legacyNamespaces, installStorageKey, rasterDataUri, validateCacheKey, type ImageUri } from './shared';
|
|
5
|
+
|
|
6
|
+
export const vectorRenderingSupported = false;
|
|
7
|
+
export const platformFetch = expoFetch as unknown as typeof globalThis.fetch;
|
|
8
|
+
// Raster images, verified Lottie JSON and MP4 clips share one namespaced directory and its budget.
|
|
9
|
+
const cacheFormats = new Map<string, { extension: string; maxBytes: number; label: string }>([
|
|
10
|
+
['image/webp', { extension: 'webp', maxBytes: SDK_LIMITS.assetBytes, label: 'Image' }],
|
|
11
|
+
['image/png', { extension: 'png', maxBytes: SDK_LIMITS.assetBytes, label: 'Image' }],
|
|
12
|
+
['application/json', { extension: 'json', maxBytes: LOTTIE_LIMITS.bytes, label: 'Animation' }],
|
|
13
|
+
['video/mp4', { extension: 'mp4', maxBytes: SDK_LIMITS.videoBytes, label: 'Clip' }],
|
|
14
|
+
]);
|
|
15
|
+
const locks = new Map<string, Promise<unknown>>();
|
|
16
|
+
function exclusive<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
17
|
+
const next = (locks.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
18
|
+
locks.set(key, next.catch(() => {}));
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
function directories(config: AssetlibConfig) {
|
|
22
|
+
const root = new Directory(Paths.document, 'assetlib-v1', namespace(config));
|
|
23
|
+
const images = new Directory(root, 'images');
|
|
24
|
+
root.create({ intermediates: true, idempotent: true });
|
|
25
|
+
images.create({ intermediates: true, idempotent: true });
|
|
26
|
+
return { root, images };
|
|
27
|
+
}
|
|
28
|
+
export function createPlatformStorage(config: AssetlibConfig): AssetStorage {
|
|
29
|
+
const scope = namespace(config);
|
|
30
|
+
let migrationChecked = false;
|
|
31
|
+
const readState = async (root: Directory) => {
|
|
32
|
+
const candidates: string[] = [];
|
|
33
|
+
// Retain a recoverable journal during replacement. An interrupted write fails
|
|
34
|
+
// closed; it must never make the replay counter silently disappear.
|
|
35
|
+
for (const name of ['state.json', 'state.next']) {
|
|
36
|
+
const file = new File(root, name);
|
|
37
|
+
if (!file.exists) continue;
|
|
38
|
+
if (file.size > SDK_LIMITS.stateBytes) throw new Error('Stored state exceeds its bound.');
|
|
39
|
+
candidates.push(await file.text());
|
|
40
|
+
}
|
|
41
|
+
return candidates.sort((a, b) => JSON.parse(b).highestSequence - JSON.parse(a).highestSequence)[0] ?? null;
|
|
42
|
+
};
|
|
43
|
+
const writeState = async (root: Directory, serialized: string) => {
|
|
44
|
+
const next = new File(root, 'state.next');
|
|
45
|
+
next.create({ overwrite: true }); next.write(serialized);
|
|
46
|
+
// The journal already contains the full replay state before the marker is
|
|
47
|
+
// written. A crash at either step cannot make this look like a fresh install.
|
|
48
|
+
const marker = new File(root, 'migration.complete');
|
|
49
|
+
marker.create({ overwrite: true }); marker.write('1');
|
|
50
|
+
await next.move(new File(root, 'state.json'), { overwrite: true });
|
|
51
|
+
};
|
|
52
|
+
const loadState = async () => {
|
|
53
|
+
const { root, images } = directories(config);
|
|
54
|
+
const current = await readState(root);
|
|
55
|
+
if (current !== null) { migrationChecked = true; return current; }
|
|
56
|
+
if (new File(root, 'migration.complete').exists) throw new Error('Previously persisted replay state is missing.');
|
|
57
|
+
if (migrationChecked) return null;
|
|
58
|
+
let selected: { root: Directory; raw: string; sequence: number; payload: string } | undefined;
|
|
59
|
+
for (const legacy of legacyNamespaces(config)) {
|
|
60
|
+
const oldRoot = new Directory(Paths.document, 'assetlib-v1', legacy);
|
|
61
|
+
let raw: string | null, state: ReturnType<typeof verifyStoredState>;
|
|
62
|
+
try {
|
|
63
|
+
raw = await readState(oldRoot);
|
|
64
|
+
if (raw === null) continue;
|
|
65
|
+
state = verifyStoredState(raw, config);
|
|
66
|
+
} catch { continue; } // Invalid old state is ignored, never removed.
|
|
67
|
+
if (selected?.sequence === state.highestSequence && selected.payload !== state.history[0].payload) throw new Error('Conflicting legacy replay state.');
|
|
68
|
+
if (!selected || state.highestSequence > selected.sequence) selected = { root: oldRoot, raw, sequence: state.highestSequence, payload: state.history[0].payload };
|
|
69
|
+
}
|
|
70
|
+
if (!selected) { migrationChecked = true; return null; }
|
|
71
|
+
// Copy bounded artwork before publishing metadata. The core rehashes every
|
|
72
|
+
// copied cache entry before use, including arm and appearance cache keys.
|
|
73
|
+
const oldImages = new Directory(selected.root, 'images');
|
|
74
|
+
let count = 0, bytes = 0;
|
|
75
|
+
if (oldImages.exists) for (const entry of oldImages.list()) {
|
|
76
|
+
if (!(entry instanceof File) || !/^[a-f0-9]{64}\.(webp|png)$/.test(entry.name) || entry.size > SDK_LIMITS.assetBytes || entry.size < 1) continue;
|
|
77
|
+
if (count >= SDK_LIMITS.cacheEntries || bytes + entry.size > SDK_LIMITS.cacheBytes) continue;
|
|
78
|
+
await entry.copy(new File(images, entry.name), { overwrite: true });
|
|
79
|
+
count++; bytes += entry.size;
|
|
80
|
+
}
|
|
81
|
+
await writeState(root, selected.raw);
|
|
82
|
+
migrationChecked = true;
|
|
83
|
+
// Destination is durable before deleting any verified source. Interrupted
|
|
84
|
+
// cleanup is harmless: the destination prevents all future legacy reads.
|
|
85
|
+
try {
|
|
86
|
+
if (await readState(selected.root) === selected.raw) for (const name of ['state.json', 'state.next']) {
|
|
87
|
+
const file = new File(selected.root, name);
|
|
88
|
+
if (file.exists) file.delete();
|
|
89
|
+
}
|
|
90
|
+
} catch { /* Retain the old copy if cleanup fails. */ }
|
|
91
|
+
return selected.raw;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
getOrCreateInstallId: (key, create) => exclusive(`install:${installStorageKey(key)}`, async () => {
|
|
95
|
+
const root = new Directory(Paths.document, 'assetlib-installs-v1');
|
|
96
|
+
root.create({ intermediates: true, idempotent: true });
|
|
97
|
+
const name = installStorageKey(key);
|
|
98
|
+
for (const suffix of ['.id', '.next']) {
|
|
99
|
+
const file = new File(root, `${name}${suffix}`);
|
|
100
|
+
if (!file.exists || file.size > 64) continue;
|
|
101
|
+
const value = await file.text();
|
|
102
|
+
if (/^[a-f0-9]{32}$/.test(value)) return value;
|
|
103
|
+
}
|
|
104
|
+
let value: string;
|
|
105
|
+
try { value = create(); }
|
|
106
|
+
catch {
|
|
107
|
+
// Expo's native runtime supplies secure UUIDs even without Web Crypto.
|
|
108
|
+
const nativeUuid = (globalThis as typeof globalThis & { expo?: { uuidv4?: () => string } }).expo?.uuidv4?.();
|
|
109
|
+
if (typeof nativeUuid !== 'string') throw new Error('Secure install ID generation is unavailable.');
|
|
110
|
+
value = nativeUuid.replaceAll('-', '').toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
if (!/^[a-f0-9]{32}$/.test(value)) throw new Error('Invalid random install ID.');
|
|
113
|
+
const next = new File(root, `${name}.next`);
|
|
114
|
+
next.create({ overwrite: true }); next.write(value);
|
|
115
|
+
await next.move(new File(root, `${name}.id`), { overwrite: true });
|
|
116
|
+
return value;
|
|
117
|
+
}),
|
|
118
|
+
loadState: () => exclusive(scope, loadState),
|
|
119
|
+
saveState: serialized => exclusive(scope, async () => {
|
|
120
|
+
if (new TextEncoder().encode(serialized).length > SDK_LIMITS.stateBytes) throw new Error('State exceeds its bound.');
|
|
121
|
+
const old = await loadState();
|
|
122
|
+
if (old) {
|
|
123
|
+
const previous = JSON.parse(old), next = JSON.parse(serialized);
|
|
124
|
+
if (previous.highestSequence > next.highestSequence || (previous.highestSequence === next.highestSequence && previous.history[0]?.payload !== next.history[0]?.payload)) throw new Error('A newer or conflicting sequence is already stored.');
|
|
125
|
+
}
|
|
126
|
+
await writeState(directories(config).root, serialized);
|
|
127
|
+
}),
|
|
128
|
+
getAsset: async hash => {
|
|
129
|
+
validateCacheKey(hash);
|
|
130
|
+
const { images } = directories(config);
|
|
131
|
+
for (const { extension, maxBytes } of cacheFormats.values()) {
|
|
132
|
+
const file = new File(images, `${hash}.${extension}`);
|
|
133
|
+
if (file.exists && file.size <= maxBytes) return file.bytes();
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
},
|
|
137
|
+
putAsset: (hash, bytes, mime = 'image/webp') => exclusive(scope, async () => {
|
|
138
|
+
const format = cacheFormats.get(mime);
|
|
139
|
+
if (!format) throw new Error('This native adapter caches PNG, WebP, Lottie JSON and MP4 only.');
|
|
140
|
+
const { extension } = format;
|
|
141
|
+
validateCacheKey(hash);
|
|
142
|
+
if (bytes.length > format.maxBytes) throw new Error(`${format.label} exceeds the cache entry bound.`);
|
|
143
|
+
const { images } = directories(config);
|
|
144
|
+
for (const entry of images.list()) if (entry instanceof File && entry.name.endsWith('.next')) entry.delete();
|
|
145
|
+
const entries = images.list().filter((entry): entry is File => entry instanceof File && /\.(webp|png|json|mp4)$/.test(entry.name) && entry.name !== `${hash}.${extension}`).sort((a, b) => (a.modificationTime ?? 0) - (b.modificationTime ?? 0));
|
|
146
|
+
let total = entries.reduce((sum, file) => sum + file.size, 0);
|
|
147
|
+
while (entries.length >= SDK_LIMITS.cacheEntries || total + bytes.length > SDK_LIMITS.cacheBytes) {
|
|
148
|
+
const oldest = entries.shift();
|
|
149
|
+
if (!oldest) break;
|
|
150
|
+
total -= oldest.size;
|
|
151
|
+
oldest.delete();
|
|
152
|
+
}
|
|
153
|
+
const next = new File(images, `${hash}.next`);
|
|
154
|
+
next.create({ overwrite: true }); next.write(bytes);
|
|
155
|
+
await next.move(new File(images, `${hash}.${extension}`), { overwrite: true });
|
|
156
|
+
}),
|
|
157
|
+
// The player opens clips from files, so the core returns this URI instead of clip bytes.
|
|
158
|
+
getAssetFileUri: async (hash, mime) => {
|
|
159
|
+
validateCacheKey(hash);
|
|
160
|
+
const format = cacheFormats.get(mime);
|
|
161
|
+
if (!format) return null;
|
|
162
|
+
const file = new File(directories(config).images, `${hash}.${format.extension}`);
|
|
163
|
+
return file.exists && file.size <= format.maxBytes ? file.uri : null;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export async function imageUri(config: AssetlibConfig, result: ResolvedAsset): Promise<ImageUri> {
|
|
168
|
+
if (!result.sha256) throw new Error('Missing verified image hash.');
|
|
169
|
+
validateCacheKey(result.sha256);
|
|
170
|
+
if (!['image/webp', 'image/png'].includes(result.mime ?? '')) throw new Error('Unsupported native image format.');
|
|
171
|
+
if (result.cachePolicy === 'memory' || result.cachePolicy === 'none') return rasterDataUri(result);
|
|
172
|
+
const cacheKey = result.cacheKey ?? result.sha256;
|
|
173
|
+
validateCacheKey(cacheKey);
|
|
174
|
+
const extension = result.mime === 'image/png' ? 'png' : 'webp';
|
|
175
|
+
const file = new File(directories(config).images, `${cacheKey}.${extension}`);
|
|
176
|
+
if (!file.exists) throw new Error('The verified image was evicted from the local cache.');
|
|
177
|
+
return { uri: file.uri, release() {} };
|
|
178
|
+
}
|
|
179
|
+
/** A verified cached clip as the player's local file. `memory` and `none` never reach here: the core gives the poster. */
|
|
180
|
+
export async function videoUri(_config: AssetlibConfig, result: ResolvedVideo): Promise<ImageUri> {
|
|
181
|
+
if (result.source === 'poster' || typeof result.uri !== 'string' || !result.uri.startsWith('file://')) throw new Error('The verified clip is not available as a local file.');
|
|
182
|
+
return { uri: result.uri, release() {} };
|
|
183
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './platform.native';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset, type ResolvedVideo } from '@assetlib/sdk-core';
|
|
2
|
+
import { namespace, legacyNamespaces, installStorageKey, validateCacheKey, type ImageUri } from './shared';
|
|
3
|
+
export const vectorRenderingSupported = true;
|
|
4
|
+
export const platformFetch: typeof globalThis.fetch = (...args) => globalThis.fetch(...args);
|
|
5
|
+
|
|
6
|
+
function database(config: AssetlibConfig, install = false, scope = namespace(config)): Promise<IDBDatabase> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
if (!globalThis.indexedDB) return reject(new Error('IndexedDB is required for persistent release verification.'));
|
|
9
|
+
const request = indexedDB.open(install ? 'assetlib-installs-v1' : `assetlib-v1-${scope}`, 1);
|
|
10
|
+
request.onupgradeneeded = () => { request.result.createObjectStore('meta'); if (!install) request.result.createObjectStore('images', { keyPath: 'hash' }); };
|
|
11
|
+
request.onsuccess = () => resolve(request.result);
|
|
12
|
+
request.onerror = () => reject(new Error('Could not open the Assetlib cache.'));
|
|
13
|
+
request.onblocked = () => reject(new Error('Close other Assetlib tabs to update this cache.'));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function createPlatformStorage(config: AssetlibConfig): AssetStorage {
|
|
17
|
+
let migrationChecked = false;
|
|
18
|
+
async function transaction<T>(storeName: 'meta' | 'images' | ['meta', 'images'], mode: IDBTransactionMode, work: (store: IDBObjectStore, done: (result: T) => void, abort: (error: Error) => void, tx: IDBTransaction) => void, install = false, scope = namespace(config)): Promise<T> {
|
|
19
|
+
const db = await database(config, install, scope);
|
|
20
|
+
return new Promise<T>((resolve, reject) => {
|
|
21
|
+
const tx = db.transaction(storeName, mode);
|
|
22
|
+
let result: T;
|
|
23
|
+
let failure: Error | null = null;
|
|
24
|
+
tx.oncomplete = () => { db.close(); resolve(result); };
|
|
25
|
+
tx.onabort = tx.onerror = () => { db.close(); reject(failure ?? new Error('Assetlib cache transaction failed.')); };
|
|
26
|
+
try { work(tx.objectStore(Array.isArray(storeName) ? storeName[0] : storeName), value => { result = value; }, error => { failure = error; tx.abort(); }, tx); }
|
|
27
|
+
catch (error) { failure = error instanceof Error ? error : new Error('Invalid cache operation.'); tx.abort(); }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const readCurrent = () => transaction<string | null>('meta', 'readonly', (store, done, abort) => {
|
|
31
|
+
const request = store.get('state');
|
|
32
|
+
request.onsuccess = () => {
|
|
33
|
+
if (request.result != null) return done(request.result);
|
|
34
|
+
const marker = store.get('migration-complete');
|
|
35
|
+
marker.onsuccess = () => marker.result ? abort(new Error('Previously persisted replay state is missing.')) : done(null);
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
const loadState = async (): Promise<string | null> => {
|
|
39
|
+
const current = await readCurrent();
|
|
40
|
+
if (current !== null) { migrationChecked = true; return current; }
|
|
41
|
+
if (migrationChecked) return null;
|
|
42
|
+
// Only probe namespaces calculated for this origin/app/environment. Signed
|
|
43
|
+
// manifests with relative URLs alone cannot establish an old cache's origin.
|
|
44
|
+
const existing = typeof globalThis.indexedDB.databases === 'function' ? new Set((await indexedDB.databases()).map(db => db.name)) : null;
|
|
45
|
+
let selected: { scope: string; raw: string; sequence: number; payload: string } | undefined;
|
|
46
|
+
for (const scope of legacyNamespaces(config)) {
|
|
47
|
+
if (existing && !existing.has(`assetlib-v1-${scope}`)) continue;
|
|
48
|
+
const raw = await transaction<string | null>('meta', 'readonly', (store, done) => { const request = store.get('state'); request.onsuccess = () => done(request.result ?? null); }, false, scope);
|
|
49
|
+
if (raw === null) continue;
|
|
50
|
+
let state: ReturnType<typeof verifyStoredState>;
|
|
51
|
+
try { state = verifyStoredState(raw, config); } catch { continue; }
|
|
52
|
+
if (selected?.sequence === state.highestSequence && selected.payload !== state.history[0].payload) throw new Error('Conflicting legacy replay state.');
|
|
53
|
+
if (!selected || state.highestSequence > selected.sequence) selected = { scope, raw, sequence: state.highestSequence, payload: state.history[0].payload };
|
|
54
|
+
}
|
|
55
|
+
if (!selected) { migrationChecked = true; return readCurrent(); }
|
|
56
|
+
const source = selected;
|
|
57
|
+
const entries = await transaction<{ hash: string; bytes: ArrayBuffer; savedAt: number }[]>('images', 'readonly', (store, done) => {
|
|
58
|
+
const request = store.getAll(); request.onsuccess = () => done(request.result);
|
|
59
|
+
}, false, source.scope);
|
|
60
|
+
const migrated = await transaction<string>(['meta', 'images'], 'readwrite', (store, done, abort, tx) => {
|
|
61
|
+
// Recheck inside the destination write transaction: another tab may have
|
|
62
|
+
// migrated or accepted a newer release while legacy state was being read.
|
|
63
|
+
const request = store.get('state');
|
|
64
|
+
request.onsuccess = () => {
|
|
65
|
+
if (request.result != null) return done(request.result);
|
|
66
|
+
const marker = store.get('migration-complete');
|
|
67
|
+
marker.onsuccess = () => {
|
|
68
|
+
if (marker.result) return abort(new Error('Previously persisted replay state is missing.'));
|
|
69
|
+
const images = tx.objectStore('images');
|
|
70
|
+
// No release is active yet. Replace interrupted migration cache data
|
|
71
|
+
// and commit bounded bytes, replay state, and marker in one transaction.
|
|
72
|
+
images.clear();
|
|
73
|
+
let count = 0, bytes = 0;
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry || !/^[a-f0-9]{64}$/.test(entry.hash) || !(entry.bytes instanceof ArrayBuffer) || entry.bytes.byteLength > SDK_LIMITS.assetBytes || entry.bytes.byteLength < 1) continue;
|
|
76
|
+
if (count >= SDK_LIMITS.cacheEntries || bytes + entry.bytes.byteLength > SDK_LIMITS.cacheBytes) continue;
|
|
77
|
+
images.put({ hash: entry.hash, bytes: entry.bytes, savedAt: Number.isFinite(entry.savedAt) ? entry.savedAt : 0 });
|
|
78
|
+
count++; bytes += entry.bytes.byteLength;
|
|
79
|
+
}
|
|
80
|
+
store.put(source.raw, 'state'); store.put(true, 'migration-complete'); done(source.raw);
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
migrationChecked = true;
|
|
85
|
+
if (migrated === source.raw) {
|
|
86
|
+
try {
|
|
87
|
+
await transaction<void>('meta', 'readwrite', (store, done) => {
|
|
88
|
+
const request = store.get('state'); request.onsuccess = () => { if (request.result === source.raw) store.delete('state'); done(undefined); };
|
|
89
|
+
}, false, source.scope);
|
|
90
|
+
} catch { /* Destination is durable; a leftover source is never read again. */ }
|
|
91
|
+
}
|
|
92
|
+
return migrated;
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
getOrCreateInstallId: (key, create) => transaction('meta', 'readwrite', (store, done, abort) => {
|
|
96
|
+
const storedKey = installStorageKey(key);
|
|
97
|
+
const request = store.get(storedKey);
|
|
98
|
+
request.onsuccess = () => {
|
|
99
|
+
try {
|
|
100
|
+
if (typeof request.result === 'string' && /^[a-f0-9]{32}$/.test(request.result)) return done(request.result);
|
|
101
|
+
const value = create();
|
|
102
|
+
if (!/^[a-f0-9]{32}$/.test(value)) return abort(new Error('Invalid random install ID.'));
|
|
103
|
+
store.put(value, storedKey); done(value);
|
|
104
|
+
} catch { abort(new Error('Could not persist the random install ID.')); }
|
|
105
|
+
};
|
|
106
|
+
}, true),
|
|
107
|
+
loadState,
|
|
108
|
+
saveState: async value => {
|
|
109
|
+
await loadState();
|
|
110
|
+
return transaction('meta', 'readwrite', (store, done, abort) => {
|
|
111
|
+
if (new TextEncoder().encode(value).length > SDK_LIMITS.stateBytes) return abort(new Error('State exceeds its bound.'));
|
|
112
|
+
const request = store.get('state');
|
|
113
|
+
request.onsuccess = () => {
|
|
114
|
+
try {
|
|
115
|
+
if (request.result) {
|
|
116
|
+
const previous = JSON.parse(request.result), next = JSON.parse(value);
|
|
117
|
+
if (previous.highestSequence > next.highestSequence || (previous.highestSequence === next.highestSequence && previous.history[0]?.payload !== next.history[0]?.payload)) return abort(new Error('A newer or conflicting sequence is already stored.'));
|
|
118
|
+
}
|
|
119
|
+
const persist = () => { store.put(value, 'state'); store.put(true, 'migration-complete'); done(undefined); };
|
|
120
|
+
if (request.result != null) return persist();
|
|
121
|
+
const marker = store.get('migration-complete');
|
|
122
|
+
marker.onsuccess = () => marker.result ? abort(new Error('Previously persisted replay state is missing.')) : persist();
|
|
123
|
+
} catch { abort(new Error('Existing replay state is corrupt.')); }
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
getAsset: hash => transaction('images', 'readonly', (store, done) => {
|
|
128
|
+
validateCacheKey(hash);
|
|
129
|
+
const request = store.get(hash);
|
|
130
|
+
request.onsuccess = () => { const bytes = request.result?.bytes; done(bytes instanceof ArrayBuffer && bytes.byteLength <= SDK_LIMITS.assetBytes ? new Uint8Array(bytes) : null); };
|
|
131
|
+
}),
|
|
132
|
+
putAsset: (hash, bytes) => transaction('images', 'readwrite', (store, done, abort) => {
|
|
133
|
+
validateCacheKey(hash);
|
|
134
|
+
if (bytes.length > SDK_LIMITS.assetBytes) return abort(new Error('Image exceeds the cache entry bound.'));
|
|
135
|
+
const request = store.getAll();
|
|
136
|
+
request.onsuccess = () => {
|
|
137
|
+
const entries = request.result.filter(entry => entry.hash !== hash).sort((a, b) => a.savedAt - b.savedAt);
|
|
138
|
+
let total = entries.reduce((sum, entry) => sum + entry.bytes.byteLength, 0);
|
|
139
|
+
while (entries.length >= SDK_LIMITS.cacheEntries || total + bytes.length > SDK_LIMITS.cacheBytes) { const oldest = entries.shift(); if (!oldest) break; total -= oldest.bytes.byteLength; store.delete(oldest.hash); }
|
|
140
|
+
store.put({ hash, bytes: bytes.slice().buffer, savedAt: Date.now() }); done(undefined);
|
|
141
|
+
};
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export async function imageUri(_config: AssetlibConfig, result: ResolvedAsset): Promise<ImageUri> {
|
|
146
|
+
if (!result.bytes) throw new Error('Missing verified image bytes.');
|
|
147
|
+
const uri = URL.createObjectURL(new Blob([result.bytes.slice().buffer as ArrayBuffer], { type: result.mime ?? 'image/webp' }));
|
|
148
|
+
try {
|
|
149
|
+
await new Promise<void>((resolve, reject) => {
|
|
150
|
+
const image = new globalThis.Image();
|
|
151
|
+
const timer = setTimeout(() => { image.onload = image.onerror = null; image.src = ''; reject(new Error('Image decoding exceeded its deadline.')); }, 8000);
|
|
152
|
+
image.onload = () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
const valid = image.naturalWidth > 0 && image.naturalHeight > 0 && image.naturalWidth <= 8192 && image.naturalHeight <= 8192 && image.naturalWidth * image.naturalHeight <= 16777216 &&
|
|
155
|
+
(result.pixelWidth === undefined || (image.naturalWidth === result.pixelWidth && image.naturalHeight === result.pixelHeight));
|
|
156
|
+
valid ? resolve() : reject(new Error('Decoded image dimensions do not match the signed rendition.'));
|
|
157
|
+
};
|
|
158
|
+
image.onerror = () => { clearTimeout(timer); reject(new Error('The verified image could not be decoded.')); };
|
|
159
|
+
image.src = uri;
|
|
160
|
+
});
|
|
161
|
+
return { uri, release: () => URL.revokeObjectURL(uri) };
|
|
162
|
+
} catch (error) { URL.revokeObjectURL(uri); throw error; }
|
|
163
|
+
}
|
|
164
|
+
/** Verified clip bytes from IndexedDB, memory or the network as a Blob URL; the component releases it on unmount. */
|
|
165
|
+
export async function videoUri(_config: AssetlibConfig, result: ResolvedVideo): Promise<ImageUri> {
|
|
166
|
+
if (result.source === 'poster' || !result.bytes?.length || result.bytes.length > SDK_LIMITS.videoBytes) throw new Error('Missing verified clip bytes.');
|
|
167
|
+
const uri = URL.createObjectURL(new Blob([result.bytes.slice().buffer as ArrayBuffer], { type: 'video/mp4' }));
|
|
168
|
+
return { uri, release: () => URL.revokeObjectURL(uri) };
|
|
169
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { hashBytes, SDK_LIMITS, storageNamespace, type AssetlibConfig, type ResolvedAsset } from '@assetlib/sdk-core';
|
|
2
|
+
export const namespace = storageNamespace;
|
|
3
|
+
|
|
4
|
+
/** Both shipped formulas, including a former single pin and either production route. */
|
|
5
|
+
export function* legacyNamespaces(config: AssetlibConfig): Generator<string> {
|
|
6
|
+
const keys = [...new Set(config.pinnedPublicKeys ?? (config.pinnedPublicKey ? [config.pinnedPublicKey] : []))].sort();
|
|
7
|
+
const pins = new Set<string | undefined>([config.pinnedPublicKey, ...keys, keys.length === 1 ? keys[0] : JSON.stringify(keys)]);
|
|
8
|
+
const base = `${new URL(config.manifestUrl).origin}/api/delivery/${config.orgId}/${config.appId}`;
|
|
9
|
+
const urls = new Set([config.manifestUrl, `${base}/environments/${config.environment}/manifest`]);
|
|
10
|
+
if (config.environment === 'production') urls.add(`${base}/manifest`);
|
|
11
|
+
let probes = 0;
|
|
12
|
+
const scopes = function* (pin: string | undefined) {
|
|
13
|
+
for (const url of urls) {
|
|
14
|
+
if (++probes > 4096) throw new Error('Legacy namespace migration exceeds its probe limit.');
|
|
15
|
+
yield hashBytes(new TextEncoder().encode([url, config.orgId, config.appId, config.environment, pin].join('\n'))).slice(0, 32);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
for (const pin of pins) yield* scopes(pin);
|
|
19
|
+
// An upgrade can coincide with adding pins. The old sorted-set formula may
|
|
20
|
+
// therefore describe any subset of the current set, including unused pins.
|
|
21
|
+
// Generate lazily within a fixed work budget. Exhaustion fails closed rather
|
|
22
|
+
// than silently treating an unsearched previous namespace as a fresh install.
|
|
23
|
+
const subsets = function* (start: number, subset: string[]): Generator<string> {
|
|
24
|
+
for (let i = start; i < keys.length; i++) {
|
|
25
|
+
const next = [...subset, keys[i]];
|
|
26
|
+
if (next.length > 1 && next.length < keys.length) yield* scopes(JSON.stringify(next));
|
|
27
|
+
yield* subsets(i + 1, next);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
yield* subsets(0, []);
|
|
31
|
+
}
|
|
32
|
+
/** The core supplies an origin/org/app key independent of environment and signing key. */
|
|
33
|
+
export const installStorageKey = (key: string): string => hashBytes(new TextEncoder().encode(key));
|
|
34
|
+
export type ImageUri = { uri: string; release(): void };
|
|
35
|
+
export function validateCacheKey(key: string): void { if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid cache key.'); }
|
|
36
|
+
|
|
37
|
+
/** Encodes only already verified raster bytes; no file or additional network request is created. */
|
|
38
|
+
export function rasterDataUri(result: ResolvedAsset): ImageUri {
|
|
39
|
+
if (!result.bytes?.length || result.bytes.length > SDK_LIMITS.assetBytes || !['image/png', 'image/webp'].includes(result.mime ?? '')) throw new Error('Missing or unsupported verified raster bytes.');
|
|
40
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
let part = '';
|
|
43
|
+
for (let offset = 0; offset < result.bytes.length; offset += 3) {
|
|
44
|
+
const a = result.bytes[offset], b = result.bytes[offset + 1], c = result.bytes[offset + 2];
|
|
45
|
+
part += alphabet[a >> 2] + alphabet[((a & 3) << 4) | ((b ?? 0) >> 4)] + (b === undefined ? '=' : alphabet[((b & 15) << 2) | ((c ?? 0) >> 6)]) + (c === undefined ? '=' : alphabet[c & 63]);
|
|
46
|
+
if (part.length >= 16384) { parts.push(part); part = ''; }
|
|
47
|
+
}
|
|
48
|
+
if (part) parts.push(part);
|
|
49
|
+
return { uri: `data:${result.mime};base64,${parts.join('')}`, release() {} };
|
|
50
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AppState, Platform } from 'react-native';
|
|
2
|
+
import type { AssetClient, ClientOptions } from '@assetlib/sdk-core';
|
|
3
|
+
|
|
4
|
+
declare const require: (name: string) => unknown;
|
|
5
|
+
type Telemetry = NonNullable<ClientOptions['telemetry']>;
|
|
6
|
+
type OptionalModule = {
|
|
7
|
+
default?: OptionalModule;
|
|
8
|
+
nativeApplicationVersion?: unknown;
|
|
9
|
+
nativeBuildVersion?: unknown;
|
|
10
|
+
nativeAppVersion?: unknown;
|
|
11
|
+
expoConfig?: { version?: unknown; ios?: { buildNumber?: unknown }; android?: { versionCode?: unknown } };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function optionalApplication(): OptionalModule | undefined {
|
|
15
|
+
try {
|
|
16
|
+
// Expo Metro marks literal requires directly in try/catch as optional.
|
|
17
|
+
const value = require('expo-application') as OptionalModule | undefined;
|
|
18
|
+
return value?.default ?? value;
|
|
19
|
+
} catch { return undefined; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function optionalConstants(): OptionalModule | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const value = require('expo-constants') as OptionalModule | undefined;
|
|
25
|
+
return value?.default ?? value;
|
|
26
|
+
} catch { return undefined; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runtimeBuild(): Telemetry['build'] {
|
|
30
|
+
try {
|
|
31
|
+
const application = optionalApplication();
|
|
32
|
+
const constants = optionalConstants();
|
|
33
|
+
const os = Platform?.OS;
|
|
34
|
+
const platform = os === 'ios' || os === 'android' || os === 'web' ? os : 'expo';
|
|
35
|
+
const appVersion = application?.nativeApplicationVersion ?? constants?.nativeAppVersion ?? constants?.expoConfig?.version;
|
|
36
|
+
const nativeBuild = application?.nativeBuildVersion ?? constants?.nativeBuildVersion;
|
|
37
|
+
const configuredBuild = os === 'ios' ? constants?.expoConfig?.ios?.buildNumber : os === 'android' ? constants?.expoConfig?.android?.versionCode : undefined;
|
|
38
|
+
const buildNumber = nativeBuild ?? configuredBuild;
|
|
39
|
+
if (typeof appVersion === 'string' && /^[A-Za-z0-9._+-]{1,64}$/.test(appVersion) && !/\s/.test(appVersion) &&
|
|
40
|
+
(typeof buildNumber === 'string' || typeof buildNumber === 'number') && /^[A-Za-z0-9._+-]{1,64}$/.test(String(buildNumber)) && !/\s/.test(String(buildNumber))) {
|
|
41
|
+
return { platform, appVersion, buildNumber: String(buildNumber) };
|
|
42
|
+
}
|
|
43
|
+
} catch { /* Optional module getters and native bridges can also throw. */ }
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function expoTelemetry(telemetry: ClientOptions['telemetry']): ClientOptions['telemetry'] {
|
|
48
|
+
if (!telemetry?.enabled) return telemetry;
|
|
49
|
+
return {
|
|
50
|
+
...telemetry,
|
|
51
|
+
sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.6.0-preview.1' },
|
|
52
|
+
build: telemetry.build ?? runtimeBuild() ?? { platform: 'expo', appVersion: 'unknown', buildNumber: 'unknown' },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function flushOnBackground(client: AssetClient): void {
|
|
57
|
+
try {
|
|
58
|
+
const subscription = AppState?.addEventListener?.('change', state => {
|
|
59
|
+
if (state === 'background') void client.flush().catch(() => {});
|
|
60
|
+
});
|
|
61
|
+
if (!subscription) return;
|
|
62
|
+
const dispose = client.dispose.bind(client);
|
|
63
|
+
client.dispose = () => {
|
|
64
|
+
try { subscription.remove(); } catch { /* Teardown must not affect the client. */ }
|
|
65
|
+
dispose();
|
|
66
|
+
};
|
|
67
|
+
} catch { /* AppState is optional in non-native renderers. */ }
|
|
68
|
+
}
|
package/src/video.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, AppState, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useVideoPlayer, VideoView, type VideoContentFit } from 'expo-video';
|
|
4
|
+
import type { AssetRef } from '@assetlib/sdk-core';
|
|
5
|
+
import { AssetlibImage, type AssetlibImageProps } from './index';
|
|
6
|
+
import { videoUri } from './platform';
|
|
7
|
+
import type { ImageUri } from './shared';
|
|
8
|
+
|
|
9
|
+
export type AssetlibVideoProps<R extends AssetRef = AssetRef> = AssetlibImageProps<R> & {
|
|
10
|
+
/** Show the poster instead of playing, for example while the view is off screen. */
|
|
11
|
+
paused?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** At most two clips play at once across every AssetlibVideo; the others show their poster until a slot frees. */
|
|
15
|
+
const maxPlaying = 2;
|
|
16
|
+
let playing = 0;
|
|
17
|
+
const waiting: (() => void)[] = [];
|
|
18
|
+
function acquirePlaybackSlot(granted: () => void): () => void {
|
|
19
|
+
let state: 'waiting' | 'playing' | 'released' = 'waiting';
|
|
20
|
+
const start = () => { state = 'playing'; playing++; granted(); };
|
|
21
|
+
if (playing < maxPlaying) start(); else waiting.push(start);
|
|
22
|
+
return () => {
|
|
23
|
+
if (state === 'waiting') waiting.splice(waiting.indexOf(start), 1);
|
|
24
|
+
else if (state === 'playing') { playing--; while (playing < maxPlaying && waiting.length) waiting.shift()!(); }
|
|
25
|
+
state = 'released';
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Unknown counts as reduced: no clip plays until the system says motion is allowed. */
|
|
30
|
+
function useReduceMotion(): boolean {
|
|
31
|
+
const [reduced, setReduced] = useState<boolean | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let mounted = true;
|
|
34
|
+
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', enabled => setReduced(enabled !== false));
|
|
35
|
+
AccessibilityInfo.isReduceMotionEnabled().then(enabled => { if (mounted) setReduced(value => value ?? enabled !== false); }, () => { if (mounted) setReduced(value => value ?? true); });
|
|
36
|
+
return () => { mounted = false; subscription?.remove(); };
|
|
37
|
+
}, []);
|
|
38
|
+
return reduced !== false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function useAppActive(): boolean {
|
|
42
|
+
const [state, setState] = useState<string | null | undefined>(() => AppState.currentState);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
try { const subscription = AppState.addEventListener('change', setState); return () => subscription.remove(); }
|
|
45
|
+
catch { return undefined; }
|
|
46
|
+
}, []);
|
|
47
|
+
return state !== 'background' && state !== 'inactive';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const videoFit = (fit: AssetlibImageProps['contentFit']): VideoContentFit => fit === 'contain' || fit === 'fill' ? fit : fit === 'none' || fit === 'scale-down' ? 'contain' : 'cover';
|
|
51
|
+
type PlayableElement = { play?: () => Promise<void> | void };
|
|
52
|
+
|
|
53
|
+
function LoopingClip({ uri, contentFit, onFirstFrame, onFailure }: { uri: string; contentFit: VideoContentFit; onFirstFrame: () => void; onFailure: () => void }) {
|
|
54
|
+
const view = useRef<VideoView>(null);
|
|
55
|
+
const callbacks = useRef({ onFirstFrame, onFailure });
|
|
56
|
+
callbacks.current = { onFirstFrame, onFailure };
|
|
57
|
+
const player = useVideoPlayer(uri, created => { created.loop = true; created.muted = true; created.keepScreenOnWhilePlaying = false; });
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let mounted = true;
|
|
60
|
+
const fail = () => { if (mounted) callbacks.current.onFailure(); };
|
|
61
|
+
const subscription = player.addListener('statusChange', ({ status }) => { if (status === 'error') fail(); });
|
|
62
|
+
try {
|
|
63
|
+
if (player.status === 'error') fail();
|
|
64
|
+
// On web, VideoView exposes its <video> element, whose play() promise rejects when autoplay is blocked.
|
|
65
|
+
const element = (view.current as unknown as { nativeRef?: { current?: PlayableElement | null } } | null)?.nativeRef?.current;
|
|
66
|
+
if (typeof element?.play === 'function') Promise.resolve(element.play()).catch(fail);
|
|
67
|
+
else player.play();
|
|
68
|
+
} catch { fail(); }
|
|
69
|
+
return () => { mounted = false; subscription.remove(); };
|
|
70
|
+
}, [player]);
|
|
71
|
+
return <VideoView ref={view} player={player} nativeControls={false} contentFit={contentFit} allowsPictureInPicture={false} playsInline style={StyleSheet.absoluteFill} onFirstFrameRender={() => callbacks.current.onFirstFrame()} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The placement's poster (an AssetlibImage, bundled fallback first) with its signed short looping clip over it.
|
|
76
|
+
* The clip resolves and plays only while motion is allowed, the app is active, `paused` is false and this view
|
|
77
|
+
* holds one of the two playback slots. It plays muted and looping with no controls; any failure shows the poster
|
|
78
|
+
* until the request (client, asset, revision or cache policy) changes. `style` sizes the container.
|
|
79
|
+
*/
|
|
80
|
+
export function AssetlibVideo<R extends AssetRef>({ paused = false, ...props }: AssetlibVideoProps<R>) {
|
|
81
|
+
const { client, asset, cachePolicy, revision = 0, style, contentFit } = props;
|
|
82
|
+
const reduceMotion = useReduceMotion();
|
|
83
|
+
const active = useAppActive();
|
|
84
|
+
const request = useMemo(() => ({}), [client, asset.key, asset.width, asset.height, revision, cachePolicy]);
|
|
85
|
+
const [failed, setFailed] = useState<object | null>(null);
|
|
86
|
+
const wantsSlot = !paused && !reduceMotion && active && failed !== request;
|
|
87
|
+
const [slot, setSlot] = useState(false);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!wantsSlot) return;
|
|
90
|
+
const release = acquirePlaybackSlot(() => setSlot(true));
|
|
91
|
+
return () => { release(); setSlot(false); };
|
|
92
|
+
}, [wantsSlot]);
|
|
93
|
+
const [clip, setClip] = useState<{ request: object; media: ImageUri } | null>(null);
|
|
94
|
+
useEffect(() => () => clip?.media.release(), [clip]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (clip && clip.request !== request) { setClip(null); return; }
|
|
97
|
+
if (clip || !wantsSlot || !slot) return;
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
client.resolveVideo(asset, { cachePolicy, signal: controller.signal }).then(async result => {
|
|
100
|
+
if (controller.signal.aborted) return;
|
|
101
|
+
if (result.source === 'poster') { setFailed(request); return; }
|
|
102
|
+
const media = await videoUri(client.config, result);
|
|
103
|
+
if (controller.signal.aborted) { media.release(); return; }
|
|
104
|
+
setClip({ request, media });
|
|
105
|
+
}).catch(() => { if (!controller.signal.aborted) setFailed(request); });
|
|
106
|
+
return () => controller.abort();
|
|
107
|
+
}, [client, request, wantsSlot, slot, clip]);
|
|
108
|
+
const play = wantsSlot && slot && clip?.request === request;
|
|
109
|
+
const [frame, setFrame] = useState(false);
|
|
110
|
+
useEffect(() => { if (!play) setFrame(false); }, [play]);
|
|
111
|
+
const poster = { ...props, style: [StyleSheet.absoluteFill, { opacity: play && frame ? 0 : 1 }] } as AssetlibImageProps<R>;
|
|
112
|
+
return (
|
|
113
|
+
<View style={[{ overflow: 'hidden' }, style as StyleProp<ViewStyle>]}>
|
|
114
|
+
{play && <LoopingClip uri={clip!.media.uri} contentFit={videoFit(contentFit)} onFirstFrame={() => setFrame(true)} onFailure={() => { setFailed(request); setClip(null); }} />}
|
|
115
|
+
<AssetlibImage<R> {...poster} />
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
118
|
+
}
|