@octane-xplat/gif 0.10.0 → 0.11.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.
Files changed (37) hide show
  1. package/README.md +43 -0
  2. package/dist/native/AnimatedImage.tsrx.js +39 -0
  3. package/dist/native/escape-props.js +12 -0
  4. package/dist/native/index.js +3 -36
  5. package/dist/native/octane-client-build.json +1 -1
  6. package/dist/native/prefetch-batch.js +27 -0
  7. package/dist/native/prefetch.js +26 -0
  8. package/dist/web/AnimatedImage.web.tsrx.js +49 -0
  9. package/dist/web/escape-props.web.js +18 -0
  10. package/dist/web/index.js +3 -38
  11. package/dist/web/octane-client-build.json +1 -1
  12. package/dist/web/prefetch-batch.js +27 -0
  13. package/dist/web/prefetch.web.js +17 -0
  14. package/package.json +1 -1
  15. package/src/AnimatedImage.tsrx +2 -0
  16. package/src/AnimatedImage.web.tsrx +2 -0
  17. package/src/escape-props.ts +15 -0
  18. package/src/escape-props.web.ts +26 -0
  19. package/src/index.macos.ts +2 -1
  20. package/src/index.ts +2 -1
  21. package/src/index.web.ts +2 -1
  22. package/src/prefetch-batch.ts +50 -0
  23. package/src/prefetch.macos.ts +8 -0
  24. package/src/prefetch.ts +38 -0
  25. package/src/prefetch.web.test.ts +104 -0
  26. package/src/prefetch.web.ts +21 -0
  27. package/src/props.ts +22 -0
  28. package/types/generated/.tsrx-typegen-manifest.json +5 -0
  29. package/types/generated/escape-props.d.ts +7 -0
  30. package/types/generated/escape-props.web.d.ts +6 -0
  31. package/types/generated/index.d.ts +2 -1
  32. package/types/generated/index.web.d.ts +2 -1
  33. package/types/generated/prefetch-batch.d.ts +10 -0
  34. package/types/generated/prefetch.d.ts +12 -0
  35. package/types/generated/prefetch.web.d.ts +7 -0
  36. package/types/generated/props.d.ts +21 -0
  37. package/types/index.macos.d.ts +3 -2
package/README.md CHANGED
@@ -24,6 +24,49 @@ import { AnimatedImage } from '@octane-xplat/gif'
24
24
  and the plugin accepts the same values natively. For Lottie JSON
25
25
  animations use [`@octane-xplat/lottie`](../lottie/README.md) instead.
26
26
 
27
+ ## Prefetch
28
+
29
+ `prefetch` warms the image engine's disk cache for URLs you expect to show
30
+ soon — bytes on disk now, decode still at display time:
31
+
32
+ ```tsx
33
+ import { prefetch } from '@octane-xplat/gif'
34
+
35
+ await prefetch(feed.map((post) => post.imageUrl)) // false if any URL fails
36
+
37
+ await prefetch(urls, {
38
+ headers: { Authorization: 'Bearer …' }, // Android only — see below
39
+ concurrency: 5, // max parallel fetches (default)
40
+ })
41
+ ```
42
+
43
+ On Android that is Fresco's `prefetchToDiskCache`; on iOS, SDWebImage's
44
+ prefetcher storing to disk. On web it loads each URL through a throwaway
45
+ `<img>` so the browser HTTP cache is warm. It resolves `false` when any URL
46
+ fails — and always `false` on macOS, where the AppKit host has no image
47
+ pipeline yet. Same contract as `@octane-xplat/image`'s `prefetch`.
48
+
49
+ Options: `headers` is forwarded to the engine — **honored on Android,
50
+ dropped on iOS** (the plugin's prefetch path lacks the request-modifier
51
+ branch its display path has; an upstream patch is needed) — and ignored on
52
+ web. `concurrency` caps parallel fetches JS-side (default 5).
53
+
54
+ ## Platform props
55
+
56
+ `ios`, `android`, and `web` escape bags apply after the shared props, the
57
+ same convention as `@octane-xplat/ui`. Native bags assign onto the ui-image
58
+ `Img` view, so engine props such as `noCache`, `decodeWidth`,
59
+ `decodeHeight`, `placeholderImageUri`, and `progressiveRenderingEnabled`
60
+ are reachable where the engine supports them:
61
+
62
+ ```tsx
63
+ <AnimatedImage
64
+ src={photo.url}
65
+ ios={{ noCache: true }}
66
+ android={{ decodeWidth: 288, decodeHeight: 288 }}
67
+ />
68
+ ```
69
+
27
70
  Per-target limits are recorded in
28
71
  [known limits](../../docs/verify/known-limits.md). Exercised by
29
72
  [`AnimatedImageDemo`](../demos/src/AnimatedImageDemo.tsrx).
@@ -0,0 +1,39 @@
1
+ import { applyEscapeProps } from "./escape-props.js";
2
+ import { defineUniversalComponent, registerElement, universalPlan, universalValue } from "@nativescript-community/octane";
3
+ import { Img } from "@nativescript-community/ui-image";
4
+ //#region src/AnimatedImage.tsrx
5
+ var __octaneUniversalPlan0 = universalPlan("nativescript", {
6
+ "kind": "host",
7
+ "type": "animatedimage",
8
+ "props": {
9
+ "animatedImageView": true,
10
+ "autoPlayAnimations": true
11
+ },
12
+ "bindings": [
13
+ ["ref", 0],
14
+ ["id", 1],
15
+ ["className", 2],
16
+ ["style", 3],
17
+ ["src", 4],
18
+ ["stretch", 5],
19
+ ["width", 6],
20
+ ["height", 7],
21
+ ["accessibilityLabel", 8]
22
+ ]
23
+ });
24
+ registerElement("animatedimage", Img);
25
+ var AnimatedImage = defineUniversalComponent("nativescript", function AnimatedImage(props) {
26
+ return universalValue(__octaneUniversalPlan0, [
27
+ (v) => applyEscapeProps(v, props),
28
+ props.id,
29
+ props.className,
30
+ props.style,
31
+ props.src,
32
+ props.stretch ?? "aspectFit",
33
+ props.width,
34
+ props.height,
35
+ props.alt
36
+ ]);
37
+ }, { "module": "@nativescript-community/octane" });
38
+ //#endregion
39
+ export { AnimatedImage };
@@ -0,0 +1,12 @@
1
+ import { isAndroid, isIOS } from "@nativescript/core";
2
+ //#region src/escape-props.ts
3
+ /** Apply only the active device's escape bag after the shared props — the
4
+ * same convention @octane-xplat/ui uses, kept local so this leaf does not
5
+ * depend on ui. */
6
+ function applyEscapeProps(view, props) {
7
+ if (!view) return;
8
+ const bag = isIOS ? props.ios : isAndroid ? props.android : void 0;
9
+ if (bag) Object.assign(view, bag);
10
+ }
11
+ //#endregion
12
+ export { applyEscapeProps };
@@ -1,36 +1,3 @@
1
- import { defineUniversalComponent, registerElement, universalPlan, universalValue } from "@nativescript-community/octane";
2
- import { Img } from "@nativescript-community/ui-image";
3
- //#region src/AnimatedImage.tsrx
4
- var __octaneUniversalPlan0 = universalPlan("nativescript", {
5
- "kind": "host",
6
- "type": "animatedimage",
7
- "props": {
8
- "animatedImageView": true,
9
- "autoPlayAnimations": true
10
- },
11
- "bindings": [
12
- ["id", 0],
13
- ["className", 1],
14
- ["style", 2],
15
- ["src", 3],
16
- ["stretch", 4],
17
- ["width", 5],
18
- ["height", 6],
19
- ["accessibilityLabel", 7]
20
- ]
21
- });
22
- registerElement("animatedimage", Img);
23
- var AnimatedImage = defineUniversalComponent("nativescript", function AnimatedImage(props) {
24
- return universalValue(__octaneUniversalPlan0, [
25
- props.id,
26
- props.className,
27
- props.style,
28
- props.src,
29
- props.stretch ?? "aspectFit",
30
- props.width,
31
- props.height,
32
- props.alt
33
- ]);
34
- }, { "module": "@nativescript-community/octane" });
35
- //#endregion
36
- export { AnimatedImage };
1
+ import { AnimatedImage } from "./AnimatedImage.tsrx.js";
2
+ import { prefetch } from "./prefetch.js";
3
+ export { AnimatedImage, prefetch };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "c6fedeaa-e421-4e30-9b60-ebee0157b035",
3
+ "buildId": "2aeebf52-3b47-473e-8519-9eb628f641fb",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -0,0 +1,27 @@
1
+ function prefetchBatch(srcs, concurrency, warm) {
2
+ const urls = Array.isArray(srcs) ? srcs : [srcs];
3
+ if (urls.length === 0) return Promise.resolve(true);
4
+ const requested = Math.floor(concurrency ?? 5);
5
+ const limit = Number.isFinite(requested) && requested > 0 ? requested : 5;
6
+ return new Promise((resolve) => {
7
+ let next = 0;
8
+ let active = Math.min(limit, urls.length);
9
+ for (let i = 0; i < active; i++) (async () => {
10
+ while (next < urls.length) {
11
+ const url = urls[next++];
12
+ try {
13
+ if (!await warm(url)) {
14
+ resolve(false);
15
+ return;
16
+ }
17
+ } catch {
18
+ resolve(false);
19
+ return;
20
+ }
21
+ }
22
+ if (--active === 0) resolve(true);
23
+ })();
24
+ });
25
+ }
26
+ //#endregion
27
+ export { prefetchBatch };
@@ -0,0 +1,26 @@
1
+ import { prefetchBatch } from "./prefetch-batch.js";
2
+ import { getImagePipeline, initialize } from "@nativescript-community/ui-image";
3
+ import { isAndroid } from "@nativescript/core";
4
+ //#region src/prefetch.ts
5
+ /** prefetch — warm the image engine's disk cache for `srcs` without displaying
6
+ * anything. Android (Fresco): `prefetchToDiskCache` stores encoded bytes on
7
+ * disk and defers bitmap decode to display. iOS (SDWebImage): the prefetcher
8
+ * stores the downloaded image to the disk cache only. Either way a later
9
+ * `AnimatedImage` with the same src hits disk instead of the network.
10
+ * Resolves false when any URL fails; resolves false when the pipeline is not
11
+ * available yet (e.g. called before app launch).
12
+ * `options.concurrency` caps parallel fetches (default 5); `options.headers`
13
+ * is honored on Android and silently dropped on iOS (upstream gap — see
14
+ * PrefetchOptions). */
15
+ function prefetch(srcs, options) {
16
+ try {
17
+ if (isAndroid) initialize();
18
+ const pipeline = getImagePipeline();
19
+ const { concurrency, ...engineOptions } = options ?? {};
20
+ return prefetchBatch(srcs, concurrency, (url) => pipeline.prefetchToDiskCache(url, engineOptions).then(() => true, () => false));
21
+ } catch {
22
+ return Promise.resolve(false);
23
+ }
24
+ }
25
+ //#endregion
26
+ export { prefetch };
@@ -0,0 +1,49 @@
1
+ import { applyEscapeProps } from "./escape-props.web.js";
2
+ import { __s, bag7, clone, queueRefAttach, queueRefDetach, setStyle, template } from "octane";
3
+ import { bindSignalAttribute, enableSignalBindings, replaceRef, setPlainAttribute, setURLAttribute } from "octane/internal/client";
4
+ //#region src/AnimatedImage.web.tsrx
5
+ enableSignalBindings(1, true);
6
+ var _t$0 = /* @__PURE__ */ template("<img/>");
7
+ var _rm$0 = [
8
+ "r",
9
+ "b",
10
+ "a"
11
+ ];
12
+ var OBJECT_FIT = {
13
+ none: "none",
14
+ fill: "fill",
15
+ aspectFit: "contain",
16
+ aspectFill: "cover"
17
+ };
18
+ var AnimatedImage = /* @__PURE__ */ __s(function AnimatedImage(props, __s, __extra) {
19
+ let _b = __s.slots[0];
20
+ if (_b === void 0) {
21
+ let _m0, _m1;
22
+ const _root = clone(_t$0);
23
+ queueRefAttach(__s, _m1 = (e) => applyEscapeProps(e, props), _m0 = _root);
24
+ (__s.cleanups ??= []).push(() => _b && queueRefDetach(_b.b, _b.a));
25
+ _b = bag7(__s, _root, _m0, _m1, void 0, void 0, void 0, void 0, void 0);
26
+ __s.refFields = _rm$0;
27
+ } else {
28
+ const _r = (e) => applyEscapeProps(e, props);
29
+ if (_r !== _b.b) _b.b = replaceRef(__s, _b.b, _r, _b.a);
30
+ }
31
+ _b.c = bindSignalAttribute(__s, _b.c, _b.a, "id", props.id, "b:524dc1ba", setPlainAttribute);
32
+ _b.d = bindSignalAttribute(__s, _b.d, _b.a, "class", props.className, "b:0279b906", "class");
33
+ {
34
+ const _v = {
35
+ objectFit: OBJECT_FIT[props.stretch ?? "aspectFit"],
36
+ width: props.width,
37
+ height: props.height,
38
+ ...props.style
39
+ };
40
+ if (_b.e !== _v) {
41
+ setStyle(_b.a, _v, _b.e);
42
+ _b.e = _v;
43
+ }
44
+ }
45
+ _b.f = bindSignalAttribute(__s, _b.f, _b.a, "src", props.src, "b:f8b000cf", setURLAttribute);
46
+ _b.g = bindSignalAttribute(__s, _b.g, _b.a, "alt", props.alt, "b:29df9938", setPlainAttribute);
47
+ });
48
+ //#endregion
49
+ export { AnimatedImage };
@@ -0,0 +1,18 @@
1
+ //#region src/escape-props.web.ts
2
+ /** Apply DOM escape props after the primitive's own props. Dash-named keys
3
+ * are attributes; other keys use the DOM property where available. Mirrors
4
+ * @octane-xplat/ui's web escape-props so this leaf does not depend on ui. */
5
+ function applyEscapeProps(element, props) {
6
+ if (!element || !props.web) return;
7
+ for (const [key, value] of Object.entries(props.web)) if (key.includes("-")) {
8
+ if (value == null || value === false) element.removeAttribute(key);
9
+ else element.setAttribute(key, value === true ? "" : String(value));
10
+ } else if (key in element) try {
11
+ element[key] = value;
12
+ } catch {
13
+ element.setAttribute(key, String(value));
14
+ }
15
+ else if (value != null) element.setAttribute(key, String(value));
16
+ }
17
+ //#endregion
18
+ export { applyEscapeProps };
package/dist/web/index.js CHANGED
@@ -1,38 +1,3 @@
1
- import { __s, bag6, clone, setStyle, template } from "octane";
2
- import { bindSignalAttribute, enableSignalBindings, setPlainAttribute, setURLAttribute } from "octane/internal/client";
3
- //#region src/AnimatedImage.web.tsrx
4
- enableSignalBindings(1, true);
5
- var _t$0 = /* @__PURE__ */ template("<img/>");
6
- var OBJECT_FIT = {
7
- none: "none",
8
- fill: "fill",
9
- aspectFit: "contain",
10
- aspectFill: "cover"
11
- };
12
- var AnimatedImage = /* @__PURE__ */ __s(function AnimatedImage(props, __s, __extra) {
13
- let _b = __s.slots[0];
14
- if (_b === void 0) {
15
- let _m1;
16
- const _root = clone(_t$0);
17
- _m1 = _root;
18
- _b = bag6(__s, _root, void 0, _m1, void 0, void 0, void 0, void 0);
19
- }
20
- _b.a = bindSignalAttribute(__s, _b.a, _b.b, "id", props.id, "b:42ec7ee7", setPlainAttribute);
21
- _b.c = bindSignalAttribute(__s, _b.c, _b.b, "class", props.className, "b:1b1f2715", "class");
22
- {
23
- const _v = {
24
- objectFit: OBJECT_FIT[props.stretch ?? "aspectFit"],
25
- width: props.width,
26
- height: props.height,
27
- ...props.style
28
- };
29
- if (_b.d !== _v) {
30
- setStyle(_b.b, _v, _b.d);
31
- _b.d = _v;
32
- }
33
- }
34
- _b.e = bindSignalAttribute(__s, _b.e, _b.b, "src", props.src, "b:3d7475fa", setURLAttribute);
35
- _b.f = bindSignalAttribute(__s, _b.f, _b.b, "alt", props.alt, "b:3f7e4d1f", setPlainAttribute);
36
- });
37
- //#endregion
38
- export { AnimatedImage };
1
+ import { AnimatedImage } from "./AnimatedImage.web.tsrx.js";
2
+ import { prefetch } from "./prefetch.web.js";
3
+ export { AnimatedImage, prefetch };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "53761028-f4c2-4203-8188-4bb35aaf8f26",
3
+ "buildId": "0940d223-5435-4ff5-98b9-95158ea69208",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -0,0 +1,27 @@
1
+ function prefetchBatch(srcs, concurrency, warm) {
2
+ const urls = Array.isArray(srcs) ? srcs : [srcs];
3
+ if (urls.length === 0) return Promise.resolve(true);
4
+ const requested = Math.floor(concurrency ?? 5);
5
+ const limit = Number.isFinite(requested) && requested > 0 ? requested : 5;
6
+ return new Promise((resolve) => {
7
+ let next = 0;
8
+ let active = Math.min(limit, urls.length);
9
+ for (let i = 0; i < active; i++) (async () => {
10
+ while (next < urls.length) {
11
+ const url = urls[next++];
12
+ try {
13
+ if (!await warm(url)) {
14
+ resolve(false);
15
+ return;
16
+ }
17
+ } catch {
18
+ resolve(false);
19
+ return;
20
+ }
21
+ }
22
+ if (--active === 0) resolve(true);
23
+ })();
24
+ });
25
+ }
26
+ //#endregion
27
+ export { prefetchBatch };
@@ -0,0 +1,17 @@
1
+ import { prefetchBatch } from "./prefetch-batch.js";
2
+ //#region src/prefetch.web.ts
3
+ /** prefetch — load each `src` through a throwaway <img> so the response lands
4
+ * in the browser HTTP cache; decode and render stay with the displaying
5
+ * element. `options.headers` is ignored — an <img> request cannot carry
6
+ * custom headers. `options.concurrency` caps parallel loads (default 5).
7
+ * Resolves false when any URL fails. */
8
+ function prefetch(srcs, options) {
9
+ return prefetchBatch(srcs, options?.concurrency, (url) => new Promise((resolve) => {
10
+ const image = new Image();
11
+ image.onload = () => resolve(true);
12
+ image.onerror = () => resolve(false);
13
+ image.src = url;
14
+ }));
15
+ }
16
+ //#endregion
17
+ export { prefetch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/gif",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Animated images (GIF/animated webp) for Octane xplat — web <img> + @nativescript-community/ui-image",
5
5
  "keywords": [
6
6
  "android",
@@ -2,6 +2,7 @@
2
2
  import { registerElement } from '@nativescript-community/octane';
3
3
  import { Img } from '@nativescript-community/ui-image';
4
4
  import type { AnimatedImageProps } from './props';
5
+ import { applyEscapeProps } from './escape-props';
5
6
 
6
7
  // @nativescript-community/ui-image — Fresco DraweeView on Android,
7
8
  // SDAnimatedImageView on iOS when `animatedImageView` is set (plain UIImageView
@@ -14,6 +15,7 @@ registerElement('animatedimage', Img);
14
15
  * the shared contract is always-playing. */
15
16
  export function AnimatedImage(props: AnimatedImageProps) @{
16
17
  <animatedimage
18
+ ref={(v: any) => applyEscapeProps(v, props)}
17
19
  id={props.id}
18
20
  className={props.className}
19
21
  style={props.style}
@@ -1,4 +1,5 @@
1
1
  import type { AnimatedImageProps } from './props';
2
+ import { applyEscapeProps } from './escape-props.web';
2
3
 
3
4
  const OBJECT_FIT = {
4
5
  none: 'none',
@@ -11,6 +12,7 @@ const OBJECT_FIT = {
11
12
  * `stretch` maps to object-fit. */
12
13
  export function AnimatedImage(props: AnimatedImageProps) @{
13
14
  <img
15
+ ref={(e: any) => applyEscapeProps(e, props)}
14
16
  id={props.id}
15
17
  className={props.className}
16
18
  style={{
@@ -0,0 +1,15 @@
1
+ import { isAndroid, isIOS } from '@nativescript/core'
2
+
3
+ /** Apply only the active device's escape bag after the shared props — the
4
+ * same convention @octane-xplat/ui uses, kept local so this leaf does not
5
+ * depend on ui. */
6
+ export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
7
+ if (!view) {
8
+ return
9
+ }
10
+
11
+ const bag = isIOS ? props.ios : isAndroid ? props.android : undefined
12
+ if (bag) {
13
+ Object.assign(view, bag)
14
+ }
15
+ }
@@ -0,0 +1,26 @@
1
+ /** Apply DOM escape props after the primitive's own props. Dash-named keys
2
+ * are attributes; other keys use the DOM property where available. Mirrors
3
+ * @octane-xplat/ui's web escape-props so this leaf does not depend on ui. */
4
+ export function applyEscapeProps(element: any, props: { web?: any }): void {
5
+ if (!element || !props.web) {
6
+ return
7
+ }
8
+
9
+ for (const [key, value] of Object.entries(props.web)) {
10
+ if (key.includes('-')) {
11
+ if (value == null || value === false) {
12
+ element.removeAttribute(key)
13
+ } else {
14
+ element.setAttribute(key, value === true ? '' : String(value))
15
+ }
16
+ } else if (key in element) {
17
+ try {
18
+ element[key] = value
19
+ } catch {
20
+ element.setAttribute(key, String(value))
21
+ }
22
+ } else if (value != null) {
23
+ element.setAttribute(key, String(value))
24
+ }
25
+ }
26
+ }
@@ -1,2 +1,3 @@
1
1
  export { AnimatedImage } from './AnimatedImage.macos.tsrx'
2
- export type { AnimatedImageProps } from './props'
2
+ export { prefetch } from './prefetch.macos'
3
+ export type { AnimatedImageProps, PrefetchOptions } from './props'
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Platform barrels — explicit .tsrx leaf imports (moduleSuffixes don't
2
2
  // reach .tsrx; same convention as @octane-xplat/ui index.*.ts).
3
3
  export { AnimatedImage } from './AnimatedImage.tsrx'
4
- export type { AnimatedImageProps } from './props'
4
+ export { prefetch } from './prefetch'
5
+ export type { AnimatedImageProps, PrefetchOptions } from './props'
package/src/index.web.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Platform barrels — explicit .tsrx leaf imports (moduleSuffixes don't
2
2
  // reach .tsrx; same convention as @octane-xplat/ui index.*.ts).
3
3
  export { AnimatedImage } from './AnimatedImage.web.tsrx'
4
- export type { AnimatedImageProps } from './props'
4
+ export { prefetch } from './prefetch.web'
5
+ export type { AnimatedImageProps, PrefetchOptions } from './props'
@@ -0,0 +1,50 @@
1
+ /** VENDORED — identical copy in packages/gif/src/prefetch-batch.ts; keep in
2
+ * sync.
3
+ *
4
+ * Shared batch driver for the image-engine leaves' `prefetch` contract:
5
+ * normalizes `string | string[]`, caps in-flight requests JS-side, and
6
+ * resolves `true` only when every URL warms — `false` on the first failure
7
+ * (in-flight requests settle on their own; the result is already decided). */
8
+
9
+ /** Matches the `maxRequests = 5` ceiling NS core's ImageCache used. */
10
+ export const DEFAULT_PREFETCH_CONCURRENCY = 5
11
+
12
+ export function prefetchBatch(
13
+ srcs: string | string[],
14
+ concurrency: number | undefined,
15
+ warm: (url: string) => Promise<boolean>,
16
+ ): Promise<boolean> {
17
+ const urls = Array.isArray(srcs) ? srcs : [srcs]
18
+ if (urls.length === 0) {
19
+ return Promise.resolve(true)
20
+ }
21
+
22
+ const requested = Math.floor(concurrency ?? DEFAULT_PREFETCH_CONCURRENCY)
23
+ const limit =
24
+ Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_PREFETCH_CONCURRENCY
25
+
26
+ return new Promise<boolean>((resolve) => {
27
+ let next = 0
28
+ let active = Math.min(limit, urls.length)
29
+ for (let i = 0; i < active; i++) {
30
+ void (async () => {
31
+ while (next < urls.length) {
32
+ const url = urls[next++]
33
+ try {
34
+ if (!(await warm(url))) {
35
+ resolve(false)
36
+ return
37
+ }
38
+ } catch {
39
+ resolve(false)
40
+ return
41
+ }
42
+ }
43
+
44
+ if (--active === 0) {
45
+ resolve(true)
46
+ }
47
+ })()
48
+ }
49
+ })
50
+ }
@@ -0,0 +1,8 @@
1
+ import type { PrefetchOptions } from './props'
2
+
3
+ /** prefetch — no-op on the AppKit host: it has no image pipeline yet
4
+ * (AnimatedImage renders a placeholder there), so nothing is warmed.
5
+ * Resolves false to report that honestly. */
6
+ export function prefetch(_srcs: string | string[], _options?: PrefetchOptions): Promise<boolean> {
7
+ return Promise.resolve(false)
8
+ }
@@ -0,0 +1,38 @@
1
+ import { isAndroid } from '@nativescript/core'
2
+ import { getImagePipeline, initialize } from '@nativescript-community/ui-image'
3
+ import { prefetchBatch } from './prefetch-batch'
4
+ import type { PrefetchOptions } from './props'
5
+
6
+ /** prefetch — warm the image engine's disk cache for `srcs` without displaying
7
+ * anything. Android (Fresco): `prefetchToDiskCache` stores encoded bytes on
8
+ * disk and defers bitmap decode to display. iOS (SDWebImage): the prefetcher
9
+ * stores the downloaded image to the disk cache only. Either way a later
10
+ * `AnimatedImage` with the same src hits disk instead of the network.
11
+ * Resolves false when any URL fails; resolves false when the pipeline is not
12
+ * available yet (e.g. called before app launch).
13
+ * `options.concurrency` caps parallel fetches (default 5); `options.headers`
14
+ * is honored on Android and silently dropped on iOS (upstream gap — see
15
+ * PrefetchOptions). */
16
+ export function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean> {
17
+ try {
18
+ // The plugin lazily initializes Fresco inside createNativeView; a prefetch
19
+ // that runs before any Img mounts has to do it first. initialize() is a
20
+ // no-op once the plugin (or the app) has initialized. iOS has no equivalent
21
+ // requirement and its initialize() is not guarded, so it stays Android-only.
22
+ if (isAndroid) {
23
+ initialize()
24
+ }
25
+
26
+ const pipeline = getImagePipeline()
27
+ const { concurrency, ...engineOptions } = options ?? {}
28
+
29
+ return prefetchBatch(srcs, concurrency, (url) =>
30
+ pipeline.prefetchToDiskCache(url, engineOptions).then(
31
+ () => true,
32
+ () => false,
33
+ ),
34
+ )
35
+ } catch {
36
+ return Promise.resolve(false)
37
+ }
38
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { prefetch } from './prefetch.web'
3
+ import { prefetchBatch } from './prefetch-batch'
4
+
5
+ // Same web prefetch contract as @octane-xplat/image: throwaway <img> per URL,
6
+ // awaited until load/error, false when any URL fails, JS-side concurrency cap.
7
+
8
+ class MockImage {
9
+ static instances: MockImage[] = []
10
+ onload: (() => void) | null = null
11
+ onerror: (() => void) | null = null
12
+ set src(_url: string) {
13
+ MockImage.instances.push(this)
14
+ }
15
+ load() {
16
+ this.onload?.()
17
+ }
18
+ fail() {
19
+ this.onerror?.()
20
+ }
21
+ }
22
+
23
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
24
+
25
+ async function drainPending(promise: Promise<boolean>) {
26
+ let result: boolean | undefined
27
+ void promise.then((value) => {
28
+ result = value
29
+ })
30
+
31
+ while (result === undefined) {
32
+ for (const image of MockImage.instances.splice(0)) {
33
+ image.load()
34
+ }
35
+
36
+ await flush()
37
+ }
38
+
39
+ return result
40
+ }
41
+
42
+ describe('prefetch (web)', () => {
43
+ afterEach(() => {
44
+ MockImage.instances = []
45
+ vi.unstubAllGlobals()
46
+ })
47
+
48
+ it('resolves true only after every <img> fires load', async () => {
49
+ vi.stubGlobal('Image', MockImage)
50
+ const promise = prefetch(['https://x/a.gif', 'https://x/b.gif'])
51
+ let settled = false
52
+ void promise.then(() => {
53
+ settled = true
54
+ })
55
+
56
+ await flush()
57
+ expect(settled).toBe(false)
58
+ expect(await drainPending(promise)).toBe(true)
59
+ })
60
+
61
+ it('resolves false when any URL fails', async () => {
62
+ vi.stubGlobal('Image', MockImage)
63
+ const promise = prefetch(['https://x/a.gif', 'https://x/b.gif'])
64
+ const [failed] = MockImage.instances.splice(0)
65
+ failed.fail()
66
+ expect(await promise).toBe(false)
67
+ })
68
+
69
+ it('accepts a single URL and resolves true for an empty batch', async () => {
70
+ vi.stubGlobal('Image', MockImage)
71
+ expect(await prefetch([])).toBe(true)
72
+ const promise = prefetch('https://x/a.gif')
73
+ expect(MockImage.instances).toHaveLength(1)
74
+ expect(await drainPending(promise)).toBe(true)
75
+ })
76
+
77
+ it('caps in-flight loads at options.concurrency', async () => {
78
+ vi.stubGlobal('Image', MockImage)
79
+ const promise = prefetch(
80
+ Array.from({ length: 7 }, (_, i) => `https://x/${i}.gif`),
81
+ { concurrency: 2, headers: { Authorization: 'Bearer x' } },
82
+ )
83
+
84
+ expect(MockImage.instances).toHaveLength(2)
85
+ MockImage.instances.splice(0).forEach((image) => image.load())
86
+ await flush()
87
+ await flush()
88
+ expect(MockImage.instances.length).toBeLessThanOrEqual(2)
89
+ expect(await drainPending(promise)).toBe(true)
90
+ })
91
+
92
+ it('defaults to five parallel loads', async () => {
93
+ vi.stubGlobal('Image', MockImage)
94
+ const promise = prefetch(Array.from({ length: 9 }, (_, i) => `https://x/${i}.gif`))
95
+ expect(MockImage.instances).toHaveLength(5)
96
+ expect(await drainPending(promise)).toBe(true)
97
+ })
98
+ })
99
+
100
+ describe('prefetchBatch', () => {
101
+ it('reports false if the warm callback throws', async () => {
102
+ expect(await prefetchBatch(['a', 'b'], 5, () => Promise.reject(new Error('boom')))).toBe(false)
103
+ })
104
+ })
@@ -0,0 +1,21 @@
1
+ import { prefetchBatch } from './prefetch-batch'
2
+ import type { PrefetchOptions } from './props'
3
+
4
+ /** prefetch — load each `src` through a throwaway <img> so the response lands
5
+ * in the browser HTTP cache; decode and render stay with the displaying
6
+ * element. `options.headers` is ignored — an <img> request cannot carry
7
+ * custom headers. `options.concurrency` caps parallel loads (default 5).
8
+ * Resolves false when any URL fails. */
9
+ export function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean> {
10
+ return prefetchBatch(
11
+ srcs,
12
+ options?.concurrency,
13
+ (url) =>
14
+ new Promise<boolean>((resolve) => {
15
+ const image = new Image()
16
+ image.onload = () => resolve(true)
17
+ image.onerror = () => resolve(false)
18
+ image.src = url
19
+ }),
20
+ )
21
+ }
package/src/props.ts CHANGED
@@ -9,4 +9,26 @@ export interface AnimatedImageProps {
9
9
  stretch?: 'none' | 'fill' | 'aspectFit' | 'aspectFill'
10
10
  width?: string | number
11
11
  height?: string | number
12
+ /** Platform-specific properties are applied after shared props. Native
13
+ * bags assign onto the ui-image `Img` view — `noCache`, `decodeWidth`,
14
+ * `decodeHeight`, `placeholderImageUri`, `progressiveRenderingEnabled`,
15
+ * `cacheKey`, and the rest of its prop surface are reachable this way;
16
+ * the shared props stay the parity contract. */
17
+ ios?: any
18
+ android?: any
19
+ web?: any
20
+ }
21
+
22
+ /** Options for `prefetch`. The same contract `@octane-xplat/image`'s
23
+ * `prefetch` carries. */
24
+ export interface PrefetchOptions {
25
+ /** Request headers for the prefetch fetch. Honored on Android (Fresco);
26
+ * **dropped on iOS** — the plugin's prefetch path builds its SDWebImage
27
+ * context without the download-request-modifier branch its `Img`
28
+ * display path has; an upstream patch is needed there. Ignored on web —
29
+ * a plain <img> cannot send custom headers. */
30
+ headers?: Record<string, string>
31
+ /** Max URLs fetched in parallel (JS-side cap). Default 5 — the same
32
+ * ceiling NS core's ImageCache used. */
33
+ concurrency?: number
12
34
  }
@@ -3,8 +3,13 @@
3
3
  "files": [
4
4
  "AnimatedImage.d.ts",
5
5
  "AnimatedImage.web.d.ts",
6
+ "escape-props.d.ts",
7
+ "escape-props.web.d.ts",
6
8
  "index.d.ts",
7
9
  "index.web.d.ts",
10
+ "prefetch-batch.d.ts",
11
+ "prefetch.d.ts",
12
+ "prefetch.web.d.ts",
8
13
  "props.d.ts"
9
14
  ]
10
15
  }
@@ -0,0 +1,7 @@
1
+ /** Apply only the active device's escape bag after the shared props — the
2
+ * same convention @octane-xplat/ui uses, kept local so this leaf does not
3
+ * depend on ui. */
4
+ export declare function applyEscapeProps(view: any, props: {
5
+ ios?: any;
6
+ android?: any;
7
+ }): void;
@@ -0,0 +1,6 @@
1
+ /** Apply DOM escape props after the primitive's own props. Dash-named keys
2
+ * are attributes; other keys use the DOM property where available. Mirrors
3
+ * @octane-xplat/ui's web escape-props so this leaf does not depend on ui. */
4
+ export declare function applyEscapeProps(element: any, props: {
5
+ web?: any;
6
+ }): void;
@@ -1,2 +1,3 @@
1
1
  export { AnimatedImage } from './AnimatedImage.js';
2
- export type { AnimatedImageProps } from './props.js';
2
+ export { prefetch } from './prefetch.js';
3
+ export type { AnimatedImageProps, PrefetchOptions } from './props.js';
@@ -1,2 +1,3 @@
1
1
  export { AnimatedImage } from './AnimatedImage.web.js';
2
- export type { AnimatedImageProps } from './props.js';
2
+ export { prefetch } from './prefetch.web.js';
3
+ export type { AnimatedImageProps, PrefetchOptions } from './props.js';
@@ -0,0 +1,10 @@
1
+ /** VENDORED — identical copy in packages/gif/src/prefetch-batch.ts; keep in
2
+ * sync.
3
+ *
4
+ * Shared batch driver for the image-engine leaves' `prefetch` contract:
5
+ * normalizes `string | string[]`, caps in-flight requests JS-side, and
6
+ * resolves `true` only when every URL warms — `false` on the first failure
7
+ * (in-flight requests settle on their own; the result is already decided). */
8
+ /** Matches the `maxRequests = 5` ceiling NS core's ImageCache used. */
9
+ export declare const DEFAULT_PREFETCH_CONCURRENCY = 5;
10
+ export declare function prefetchBatch(srcs: string | string[], concurrency: number | undefined, warm: (url: string) => Promise<boolean>): Promise<boolean>;
@@ -0,0 +1,12 @@
1
+ import type { PrefetchOptions } from './props.js';
2
+ /** prefetch — warm the image engine's disk cache for `srcs` without displaying
3
+ * anything. Android (Fresco): `prefetchToDiskCache` stores encoded bytes on
4
+ * disk and defers bitmap decode to display. iOS (SDWebImage): the prefetcher
5
+ * stores the downloaded image to the disk cache only. Either way a later
6
+ * `AnimatedImage` with the same src hits disk instead of the network.
7
+ * Resolves false when any URL fails; resolves false when the pipeline is not
8
+ * available yet (e.g. called before app launch).
9
+ * `options.concurrency` caps parallel fetches (default 5); `options.headers`
10
+ * is honored on Android and silently dropped on iOS (upstream gap — see
11
+ * PrefetchOptions). */
12
+ export declare function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean>;
@@ -0,0 +1,7 @@
1
+ import type { PrefetchOptions } from './props.js';
2
+ /** prefetch — load each `src` through a throwaway <img> so the response lands
3
+ * in the browser HTTP cache; decode and render stay with the displaying
4
+ * element. `options.headers` is ignored — an <img> request cannot carry
5
+ * custom headers. `options.concurrency` caps parallel loads (default 5).
6
+ * Resolves false when any URL fails. */
7
+ export declare function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean>;
@@ -9,4 +9,25 @@ export interface AnimatedImageProps {
9
9
  stretch?: 'none' | 'fill' | 'aspectFit' | 'aspectFill';
10
10
  width?: string | number;
11
11
  height?: string | number;
12
+ /** Platform-specific properties are applied after shared props. Native
13
+ * bags assign onto the ui-image `Img` view — `noCache`, `decodeWidth`,
14
+ * `decodeHeight`, `placeholderImageUri`, `progressiveRenderingEnabled`,
15
+ * `cacheKey`, and the rest of its prop surface are reachable this way;
16
+ * the shared props stay the parity contract. */
17
+ ios?: any;
18
+ android?: any;
19
+ web?: any;
20
+ }
21
+ /** Options for `prefetch`. The same contract `@octane-xplat/image`'s
22
+ * `prefetch` carries. */
23
+ export interface PrefetchOptions {
24
+ /** Request headers for the prefetch fetch. Honored on Android (Fresco);
25
+ * **dropped on iOS** — the plugin's prefetch path builds its SDWebImage
26
+ * context without the download-request-modifier branch its `Img`
27
+ * display path has; an upstream patch is needed there. Ignored on web —
28
+ * a plain <img> cannot send custom headers. */
29
+ headers?: Record<string, string>;
30
+ /** Max URLs fetched in parallel (JS-side cap). Default 5 — the same
31
+ * ceiling NS core's ImageCache used. */
32
+ concurrency?: number;
12
33
  }
@@ -1,4 +1,5 @@
1
- import type { AnimatedImageProps } from './generated/props.js'
1
+ import type { AnimatedImageProps, PrefetchOptions } from './generated/props.js'
2
2
 
3
- export type { AnimatedImageProps } from './generated/props.js'
3
+ export type { AnimatedImageProps, PrefetchOptions } from './generated/props.js'
4
4
  export declare function AnimatedImage(props: AnimatedImageProps): unknown
5
+ export declare function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean>