@assetlib/sdk-expo 0.5.3-preview.1 → 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/README.md CHANGED
@@ -184,13 +184,42 @@ For example, `createExpoAssetClient(config, { cachePolicy: 'memory' })` keeps ar
184
184
 
185
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
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, 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.
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
188
 
189
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
190
 
191
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
192
 
193
- ## Optional runtime observations
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
+
194
223
 
195
224
  Telemetry is **off by default**. Enable it explicitly to report which published placement artwork the app resolves and attaches to an image renderer:
196
225
 
@@ -208,7 +237,7 @@ await client.flush();
208
237
  client.dispose(); // Remove the AppState listener and stop observation timers.
209
238
  ```
210
239
 
211
- The adapter identifies itself as `sdk-expo` version `0.5.3-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`.
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`.
212
241
 
213
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.
214
243
 
@@ -222,4 +251,4 @@ Enabling telemetry changes the app's data collection behavior. Review Google Pla
222
251
 
223
252
  ## Validation
224
253
 
225
- `npm test` exercises component lifecycle behavior with a React test renderer and mocked platform boundaries, including state-family activation, pinned selection, cancellation, fallback, and native no-file data URI generation. `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.
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,13 +1,14 @@
1
1
  {
2
2
  "name": "@assetlib/sdk-expo",
3
- "version": "0.5.3-preview.1",
3
+ "version": "0.6.0-preview.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
7
7
  "types": "./src/index.tsx",
8
8
  "react-native": "./src/index.tsx",
9
9
  "exports": {
10
- ".": "./src/index.tsx"
10
+ ".": "./src/index.tsx",
11
+ "./video": "./src/video.tsx"
11
12
  },
12
13
  "files": [
13
14
  "src",
@@ -19,15 +20,21 @@
19
20
  "test": "node --test test/*.test.mjs"
20
21
  },
21
22
  "dependencies": {
22
- "@assetlib/sdk-core": "0.5.3-preview.1"
23
+ "@assetlib/sdk-core": "0.6.0-preview.1"
23
24
  },
24
25
  "peerDependencies": {
25
26
  "expo": ">=57 <58",
26
27
  "expo-file-system": ">=57 <58",
27
28
  "expo-image": ">=57 <58",
29
+ "expo-video": ">=57 <58",
28
30
  "react": ">=19 <20",
29
31
  "react-native": ">=0.86 <0.87"
30
32
  },
33
+ "peerDependenciesMeta": {
34
+ "expo-video": {
35
+ "optional": true
36
+ }
37
+ },
31
38
  "devDependencies": {
32
39
  "@types/react": "^19.2.0",
33
40
  "react": "19.2.3",
package/src/index.tsx CHANGED
@@ -6,9 +6,9 @@ import { createPlatformStorage, imageUri, platformFetch, vectorRenderingSupporte
6
6
  import type { ImageUri } from './shared';
7
7
  import { expoTelemetry, flushOnBackground } from './telemetry';
8
8
  export { resolveAccessibilityDescription } from '@assetlib/sdk-core';
9
- export type { AssetAccessibility, AssetClient, AssetlibConfig, AssetRef, AssetRendering, AssetStatus, CachePolicy, ClientStatus, DynamicAssetRef, RefreshResult, StateSetRef, TelemetryOptions } 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
10
 
11
- export function createExpoAssetClient(config: AssetlibConfig, options: { allowInsecureLoopback?: boolean; timeoutMs?: number; decisionTimeoutMs?: number; allowVector?: boolean; cachePolicy?: CachePolicy; decide?: ClientOptions['decide']; telemetry?: ClientOptions['telemetry'] } = {}): AssetClient {
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
12
  const validated = parsePublicConfig(config, options);
13
13
  if (options.allowVector && !vectorRenderingSupported) throw new Error('SVG delivery is supported only by the browser adapter.');
14
14
  const telemetry = expoTelemetry(options.telemetry);
@@ -1,15 +1,16 @@
1
1
  import { Directory, File, Paths } from 'expo-file-system';
2
2
  import { fetch as expoFetch } from 'expo/fetch';
3
- import { LOTTIE_LIMITS, SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset } from '@assetlib/sdk-core';
3
+ import { LOTTIE_LIMITS, SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset, type ResolvedVideo } from '@assetlib/sdk-core';
4
4
  import { namespace, legacyNamespaces, installStorageKey, rasterDataUri, validateCacheKey, type ImageUri } from './shared';
5
5
 
6
6
  export const vectorRenderingSupported = false;
7
7
  export const platformFetch = expoFetch as unknown as typeof globalThis.fetch;
8
- // Raster images and verified Lottie JSON share one namespaced directory and its budget.
9
- const cacheFormats = new Map<string, { extension: string; maxBytes: number }>([
10
- ['image/webp', { extension: 'webp', maxBytes: SDK_LIMITS.assetBytes }],
11
- ['image/png', { extension: 'png', maxBytes: SDK_LIMITS.assetBytes }],
12
- ['application/json', { extension: 'json', maxBytes: LOTTIE_LIMITS.bytes }],
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' }],
13
14
  ]);
14
15
  const locks = new Map<string, Promise<unknown>>();
15
16
  function exclusive<T>(key: string, fn: () => Promise<T>): Promise<T> {
@@ -135,13 +136,13 @@ export function createPlatformStorage(config: AssetlibConfig): AssetStorage {
135
136
  },
136
137
  putAsset: (hash, bytes, mime = 'image/webp') => exclusive(scope, async () => {
137
138
  const format = cacheFormats.get(mime);
138
- if (!format) throw new Error('This native adapter caches PNG, WebP and Lottie JSON only.');
139
+ if (!format) throw new Error('This native adapter caches PNG, WebP, Lottie JSON and MP4 only.');
139
140
  const { extension } = format;
140
141
  validateCacheKey(hash);
141
- if (bytes.length > format.maxBytes) throw new Error(`${mime === 'application/json' ? 'Animation' : 'Image'} exceeds the cache entry bound.`);
142
+ if (bytes.length > format.maxBytes) throw new Error(`${format.label} exceeds the cache entry bound.`);
142
143
  const { images } = directories(config);
143
144
  for (const entry of images.list()) if (entry instanceof File && entry.name.endsWith('.next')) entry.delete();
144
- const entries = images.list().filter((entry): entry is File => entry instanceof File && /\.(webp|png|json)$/.test(entry.name) && entry.name !== `${hash}.${extension}`).sort((a, b) => (a.modificationTime ?? 0) - (b.modificationTime ?? 0));
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));
145
146
  let total = entries.reduce((sum, file) => sum + file.size, 0);
146
147
  while (entries.length >= SDK_LIMITS.cacheEntries || total + bytes.length > SDK_LIMITS.cacheBytes) {
147
148
  const oldest = entries.shift();
@@ -153,6 +154,14 @@ export function createPlatformStorage(config: AssetlibConfig): AssetStorage {
153
154
  next.create({ overwrite: true }); next.write(bytes);
154
155
  await next.move(new File(images, `${hash}.${extension}`), { overwrite: true });
155
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
+ },
156
165
  };
157
166
  }
158
167
  export async function imageUri(config: AssetlibConfig, result: ResolvedAsset): Promise<ImageUri> {
@@ -167,3 +176,8 @@ export async function imageUri(config: AssetlibConfig, result: ResolvedAsset): P
167
176
  if (!file.exists) throw new Error('The verified image was evicted from the local cache.');
168
177
  return { uri: file.uri, release() {} };
169
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
+ }
@@ -1,4 +1,4 @@
1
- import { SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset } from '@assetlib/sdk-core';
1
+ import { SDK_LIMITS, verifyStoredState, type AssetlibConfig, type AssetStorage, type ResolvedAsset, type ResolvedVideo } from '@assetlib/sdk-core';
2
2
  import { namespace, legacyNamespaces, installStorageKey, validateCacheKey, type ImageUri } from './shared';
3
3
  export const vectorRenderingSupported = true;
4
4
  export const platformFetch: typeof globalThis.fetch = (...args) => globalThis.fetch(...args);
@@ -161,3 +161,9 @@ export async function imageUri(_config: AssetlibConfig, result: ResolvedAsset):
161
161
  return { uri, release: () => URL.revokeObjectURL(uri) };
162
162
  } catch (error) { URL.revokeObjectURL(uri); throw error; }
163
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/telemetry.ts CHANGED
@@ -48,7 +48,7 @@ export function expoTelemetry(telemetry: ClientOptions['telemetry']): ClientOpti
48
48
  if (!telemetry?.enabled) return telemetry;
49
49
  return {
50
50
  ...telemetry,
51
- sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.5.3-preview.1' },
51
+ sdk: telemetry.sdk ?? { name: 'sdk-expo', version: '0.6.0-preview.1' },
52
52
  build: telemetry.build ?? runtimeBuild() ?? { platform: 'expo', appVersion: 'unknown', buildNumber: 'unknown' },
53
53
  };
54
54
  }
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
+ }