@octane-xplat/image 0.0.0-stage → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -2
  3. package/dist/native/Image.tsrx.js +264 -0
  4. package/dist/native/cache.js +64 -0
  5. package/dist/native/hash-image.js +285 -0
  6. package/dist/native/image-binding.js +103 -0
  7. package/dist/native/image-content.js +207 -0
  8. package/dist/native/index.js +3 -0
  9. package/dist/native/octane-client-build.json +8 -0
  10. package/dist/native/prefetch-batch.js +27 -0
  11. package/dist/web/Image.web.tsrx.js +123 -0
  12. package/dist/web/cache.web.js +35 -0
  13. package/dist/web/hash-image.js +285 -0
  14. package/dist/web/image-content.js +134 -0
  15. package/dist/web/index.js +3 -0
  16. package/dist/web/octane-client-build.json +8 -0
  17. package/dist/web/prefetch-batch.js +27 -0
  18. package/package.json +83 -3
  19. package/src/Image.macos.tsrx +24 -0
  20. package/src/Image.tsrx +269 -0
  21. package/src/Image.web.tsrx +176 -0
  22. package/src/cache.macos.ts +31 -0
  23. package/src/cache.ts +93 -0
  24. package/src/cache.web.ts +49 -0
  25. package/src/hash-image.ts +313 -0
  26. package/src/image-binding.ts +170 -0
  27. package/src/image-content.ts +310 -0
  28. package/src/index.macos.ts +26 -0
  29. package/src/index.ts +28 -0
  30. package/src/index.web.ts +28 -0
  31. package/src/native-elements.d.ts +8 -0
  32. package/src/prefetch-batch.ts +50 -0
  33. package/src/prefetch.web.test.ts +105 -0
  34. package/src/props.ts +119 -0
  35. package/types/generated/.tsrx-typegen-manifest.json +16 -0
  36. package/types/generated/Image.d.ts +16 -0
  37. package/types/generated/Image.web.d.ts +12 -0
  38. package/types/generated/cache.d.ts +28 -0
  39. package/types/generated/cache.web.d.ts +16 -0
  40. package/types/generated/hash-image.d.ts +14 -0
  41. package/types/generated/image-binding.d.ts +40 -0
  42. package/types/generated/image-content.d.ts +57 -0
  43. package/types/generated/index.d.ts +3 -0
  44. package/types/generated/index.web.d.ts +3 -0
  45. package/types/generated/prefetch-batch.d.ts +10 -0
  46. package/types/generated/props.d.ts +99 -0
  47. package/types/index.macos.d.ts +28 -0
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@octane-xplat/image",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.11.0",
4
+ "description": "Cached image loading for Octane xplat — Glide/SDWebImage engine (ui-image) on native, <img> on web",
5
+ "keywords": [
6
+ "android",
7
+ "cache",
8
+ "cross-platform",
9
+ "glide",
10
+ "image",
11
+ "ios",
12
+ "nativescript",
13
+ "octane",
14
+ "sdwebimage",
15
+ "web",
16
+ "xplat"
17
+ ],
18
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/image",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/octane-xplat/octane-xplat",
23
+ "directory": "packages/image"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "types",
28
+ "src"
29
+ ],
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "macos": {
34
+ "types": "./types/index.macos.d.ts",
35
+ "default": "./src/index.macos.ts"
36
+ },
37
+ "web": {
38
+ "types": "./types/generated/index.web.d.ts",
39
+ "default": "./dist/web/index.js"
40
+ },
41
+ "native": {
42
+ "types": "./types/generated/index.d.ts",
43
+ "default": "./dist/native/index.js"
44
+ },
45
+ "types": "./types/generated/index.d.ts",
46
+ "default": "./dist/native/index.js"
47
+ }
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "dependencies": {
53
+ "@nativescript-community/ui-image": "5.0.17"
54
+ },
55
+ "devDependencies": {
56
+ "@nativescript-community/octane": "0.2.4",
57
+ "@nativescript/core": "9.1.2",
58
+ "@octane-xplat/ui": "0.11.0",
59
+ "@octanejs/vite-plugin": "0.1.61",
60
+ "@tsrx/typescript-plugin": "0.4.11",
61
+ "octane": "0.6.3",
62
+ "tsrx-typegen": "0.2.2",
63
+ "typescript": "5.9.3",
64
+ "vite": "8.3.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@nativescript-community/octane": ">=0.2.1 <1",
68
+ "@nativescript/core": ">=9.1.0 <10",
69
+ "@octane-xplat/ui": ">=0.10.0 <1",
70
+ "octane": ">=0.6.3 <1"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@nativescript-community/octane": {
74
+ "optional": true
75
+ },
76
+ "@nativescript/core": {
77
+ "optional": true
78
+ }
79
+ },
80
+ "scripts": {
81
+ "build": "vite build && vite build --mode native && pnpm typegen && node ../../scripts/check-native-dist.mjs",
82
+ "test:packed": "node tests/packed-consumer.mjs",
83
+ "typegen": "tsrx-typegen --target octane",
84
+ "pack:check": "tsrx-typegen --pack-check"
85
+ }
6
86
  }
@@ -0,0 +1,24 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import type { ImageProps } from './props'
3
+
4
+ /** Image — the AppKit host's <image>. The engine leaf has no macOS backend
5
+ * (Glide/SDWebImage are iOS/Android), so this mirrors the core AppKit path:
6
+ * local src grammar, first array entry for multi-source, contentFit →
7
+ * style.objectFit ('cover' degrades to scale-down — AppKit has no cover
8
+ * scaling mode); contentPosition is unimplemented here. Engine props are
9
+ * ignored — no cache engine exists on this target. */
10
+ export function Image(props: ImageProps) @{
11
+ const src = (Array.isArray(props.src) ? props.src[0]?.uri : props.src) ?? ''
12
+ const style = {
13
+ objectFit: props.contentFit ?? 'cover',
14
+ ...props.style,
15
+ }
16
+
17
+ <image
18
+ id={props.id}
19
+ src={src}
20
+ className={props.className}
21
+ {style}
22
+ accessibilityLabel={props.alt ?? ''}
23
+ />
24
+ }
package/src/Image.tsrx ADDED
@@ -0,0 +1,269 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { registerElement } from '@nativescript-community/octane';
3
+ import { ImageSource, isAndroid, isIOS, Screen } from '@nativescript/core';
4
+ import { Img, initialize } from '@nativescript-community/ui-image';
5
+ import { useMemo, useRef } from 'octane';
6
+ import type { ImageProps } from './props';
7
+ import { hashToPngBase64 } from './hash-image';
8
+ import {
9
+ contentFitToStretch,
10
+ needsContentRect,
11
+ resolveContentFit,
12
+ resolveContentPosition,
13
+ } from './image-content';
14
+
15
+ import { createImageBinding } from './image-binding';
16
+
17
+ // @nativescript-community/ui-image v5 — Glide + okhttp3 on Android (sized LRU
18
+ // memory cache + BitmapPool + disk cache), SDWebImage on iOS. `noCache` maps to
19
+ // skipMemoryCache + DiskCacheStrategy.NONE / SDWebImage FromLoaderOnly.
20
+ registerElement('cachedimage', Img);
21
+
22
+ let syncDecodeWarned = false;
23
+
24
+ let engineStarted = false;
25
+ function ensureEngine(): void {
26
+ if (!engineStarted) {
27
+ // Android auto-inits inside createNativeView anyway; calling early covers
28
+ // iOS loader registration and keeps an initializeImageCache() config the
29
+ // winner when it ran before first render (the plugin guards on its own
30
+ // `initialized` flag).
31
+ initialize();
32
+ engineStarted = true;
33
+ }
34
+ }
35
+
36
+ const LAYOUT_KEYS = [
37
+ 'row',
38
+ 'col',
39
+ 'rowSpan',
40
+ 'colSpan',
41
+ 'dock',
42
+ 'left',
43
+ 'top',
44
+ 'flexGrow',
45
+ 'flexShrink',
46
+ 'alignSelf',
47
+ 'order',
48
+ ] as const;
49
+
50
+ function layoutChildAttrs(props: ImageProps) {
51
+ const result: Record<string, any> = {};
52
+ for (const key of LAYOUT_KEYS) {
53
+ if (props[key] !== undefined) {
54
+ result[key] = props[key];
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ function applyEscapeProps(view: any, props: ImageProps): void {
62
+ const bag = isIOS ? props.ios : isAndroid ? props.android : undefined;
63
+ if (bag) {
64
+ Object.assign(view, bag);
65
+ }
66
+ }
67
+
68
+ /** A `src` string → engine source. data: URIs decode to an ImageSource — the
69
+ * engine has no data: scheme. */
70
+ function resolveSrc(src: string): string | ImageSource {
71
+ const comma = src.indexOf(',');
72
+ if (src.startsWith('data:') && comma > 0 && src.slice(0, comma).endsWith(';base64')) {
73
+ return ImageSource.fromBase64Sync(src.slice(comma + 1));
74
+ }
75
+
76
+ return src;
77
+ }
78
+
79
+ /** `placeholder` → what the engine's placeholderImageUri accepts: `res://`,
80
+ * `~/`, and file paths pass through as strings; `blurhash:`/`thumbhash:` and
81
+ * `data:` decode to an ImageSource (the plugin's getDrawable/getUIImage
82
+ * accept the instance even though the prop is typed `string`). Remote URLs
83
+ * are ignored on native, matching core Image — a placeholder that must fetch
84
+ * first is pointless. */
85
+ function placeholderValue(placeholder: string): string | ImageSource | null {
86
+ const png = hashToPngBase64(placeholder);
87
+ if (png) {
88
+ return ImageSource.fromBase64Sync(png);
89
+ }
90
+
91
+ if (/^https?:\/\//.test(placeholder)) {
92
+ return null;
93
+ }
94
+
95
+ return resolveSrc(placeholder);
96
+ }
97
+
98
+ /** Image — ui-image `Img` (Glide/SDWebImage). Mirrors the core contract:
99
+ * `src` takes the Img grammar (remote URL, res://name, ~/bundle path, file
100
+ * path, data: URI) plus a resolution-qualified `ImageSourceLike[]` (closest
101
+ * pixel count wins once the view has a real layout — the srcset model).
102
+ * `contentFit` maps to stretch for the centered contain/cover/fill cases;
103
+ * 'none'/'scale-down'/positioned content take the computed path — the host
104
+ * box is measured, the content rect computed, and a fill-stretched `Img`
105
+ * laid out inside a clipped absolutelayout (same structure core uses).
106
+ * `src` never goes through JSX: the binding holds it until first layout,
107
+ * writes decodeWidth/decodeHeight in device px, then assigns — an eager src
108
+ * decodes at full source resolution. SVG is unsupported — keep core `Image`
109
+ * for those. `alt` maps to accessibilityLabel. `decoding` accepts the shared
110
+ * vocabulary but 'sync' degrades to async with a warning — the engines have
111
+ * no synchronous decode mode (see props.ts). */
112
+ export function Image(props: ImageProps) @{
113
+ ensureEngine();
114
+
115
+ // `decoding='sync'` has no honest path: Glide `.into()` and SDWebImage
116
+ // `sd_setImageWithURL` always decode off-thread, and the plugin's
117
+ // `loadMode` prop is registered but never read. Degrade to the async
118
+ // default with a note rather than setting dead surface.
119
+ if (props.decoding === 'sync' && !syncDecodeWarned) {
120
+ syncDecodeWarned = true;
121
+ console.warn(
122
+ "[octane-xplat] Image: decoding='sync' is not supported by the Glide/SDWebImage engine — decode stays async.",
123
+ );
124
+ }
125
+
126
+ const latest = useRef(props);
127
+ latest.current = props;
128
+
129
+ const fit = resolveContentFit(props.contentFit);
130
+ const position = resolveContentPosition(props.contentPosition);
131
+ const computed = needsContentRect(fit, position);
132
+ const imageRef = useRef<any>(null);
133
+ const hostRef = useRef<any>(null);
134
+
135
+ const binding = useMemo(
136
+ () =>
137
+ createImageBinding({
138
+ getFit: () => resolveContentFit(latest.current.contentFit),
139
+ getPosition: () => resolveContentPosition(latest.current.contentPosition),
140
+ getSrc: () => {
141
+ const src = latest.current.src;
142
+ return Array.isArray(src)
143
+ ? { source: null, sources: src }
144
+ : { source: resolveSrc(src), sources: null };
145
+ },
146
+ getDecodeBounds: () => ({
147
+ width: latest.current.decodeWidth,
148
+ height: latest.current.decodeHeight,
149
+ }),
150
+ screenScale: () => Screen.mainScreen?.scale ?? 1,
151
+ }),
152
+ [],
153
+ );
154
+
155
+ const boundRef = useRef({ key: props.recyclingKey, src: props.src });
156
+ const bound = boundRef.current;
157
+ boundRef.current = { key: props.recyclingKey, src: props.src };
158
+ // expo-image `recyclingKey`: platform-list cells rebind the same view, and
159
+ // the engine keeps drawing the previous bitmap until the new fetch
160
+ // resolves. Clear it before the ref re-binds the next src — only when
161
+ // identity AND source both changed (same src = same bitmap).
162
+ if (
163
+ bound.key != null &&
164
+ props.recyclingKey != null &&
165
+ bound.key !== props.recyclingKey &&
166
+ bound.src !== props.src &&
167
+ imageRef.current
168
+ ) {
169
+ const nativeView = imageRef.current.nativeViewProtected;
170
+ if (nativeView) {
171
+ if (isIOS) {
172
+ nativeView.image = null;
173
+ } else {
174
+ nativeView.setImageDrawable?.(null);
175
+ }
176
+ }
177
+ }
178
+
179
+ const attach = (view: any) => {
180
+ if (!view) {
181
+ return;
182
+ }
183
+
184
+ imageRef.current = view;
185
+ applyEscapeProps(view, props);
186
+ const ph = latest.current.placeholder;
187
+ if (ph) {
188
+ const resolved = placeholderValue(ph);
189
+ if (resolved != null) {
190
+ // string | ImageSource — the prop is typed `string` but the
191
+ // plugin's placeholder resolution accepts an ImageSource on
192
+ // both platforms.
193
+ view.placeholderImageUri = resolved;
194
+ }
195
+ }
196
+
197
+ if (!view.__xplatImageWired) {
198
+ view.__xplatImageWired = true;
199
+ view.on(Img.finalImageSetEvent, (e: any) => {
200
+ const info = e.imageInfo;
201
+ const w = info?.getWidth?.() ?? info?.width ?? 0;
202
+ const h = info?.getHeight?.() ?? info?.height ?? 0;
203
+ binding.setIntrinsic(w, h);
204
+ latest.current.onLoad?.({ width: w, height: h, source: e.source });
205
+ });
206
+
207
+ view.on(Img.failureEvent, (e: any) => {
208
+ latest.current.onError?.({ error: e.error });
209
+ });
210
+ }
211
+ };
212
+
213
+ @if (computed) {
214
+ <absolutelayout
215
+ ref={(v: any) => {
216
+ hostRef.current = v;
217
+ if (v && isIOS && v.ios) {
218
+ // cover/positioned rects must clip at the component box (iOS
219
+ // defaults overflow:visible).
220
+ v.ios.clipsToBounds = true;
221
+ }
222
+
223
+ if (v) {
224
+ binding.update(imageRef.current, v);
225
+ }
226
+ }}
227
+ id={props.id}
228
+ className={props.className}
229
+ style={props.style}
230
+ {...layoutChildAttrs(props)}
231
+ >
232
+ <cachedimage
233
+ ref={(v: any) => {
234
+ attach(v);
235
+ if (v) {
236
+ binding.update(v, hostRef.current);
237
+ }
238
+ }}
239
+ stretch="fill"
240
+ failureImageUri={props.failureImage}
241
+ headers={props.headers}
242
+ noCache={props.cachePolicy === 'none'}
243
+ progressiveRenderingEnabled={props.progressive}
244
+ fadeDuration={props.fadeDuration}
245
+ accessibilityLabel={props.alt}
246
+ />
247
+ </absolutelayout>
248
+ } @else {
249
+ <cachedimage
250
+ ref={(v: any) => {
251
+ attach(v);
252
+ if (v) {
253
+ binding.update(v, null);
254
+ }
255
+ }}
256
+ id={props.id}
257
+ className={props.className}
258
+ style={props.style}
259
+ {...layoutChildAttrs(props)}
260
+ stretch={contentFitToStretch(fit)}
261
+ failureImageUri={props.failureImage}
262
+ headers={props.headers}
263
+ noCache={props.cachePolicy === 'none'}
264
+ progressiveRenderingEnabled={props.progressive}
265
+ fadeDuration={props.fadeDuration}
266
+ accessibilityLabel={props.alt}
267
+ />
268
+ }
269
+ }
@@ -0,0 +1,176 @@
1
+ import { useLayoutEffect, useRef } from 'octane';
2
+ import type { ImageProps, ImageSourceLike } from './props';
3
+ import {
4
+ imageSrcSet,
5
+ objectPositionCSSValue,
6
+ resolveContentFit,
7
+ resolveContentPosition,
8
+ } from './image-content';
9
+
10
+ import { hashToPngBase64 } from './hash-image';
11
+
12
+ /** Parent-layout metadata → CSS. Mirrors the ui web mapping: grid indices are
13
+ * zero-based, dock has no CSS equivalent and is ignored. */
14
+ function layoutChildStyle(props: ImageProps) {
15
+ const style: Record<string, any> = {};
16
+ if (props.row !== undefined) {
17
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`;
18
+ } else if (props.rowSpan !== undefined) {
19
+ style.gridRow = `span ${props.rowSpan}`;
20
+ }
21
+
22
+ if (props.col !== undefined) {
23
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`;
24
+ } else if (props.colSpan !== undefined) {
25
+ style.gridColumn = `span ${props.colSpan}`;
26
+ }
27
+
28
+ if (props.left !== undefined) {
29
+ style.left = props.left;
30
+ }
31
+
32
+ if (props.top !== undefined) {
33
+ style.top = props.top;
34
+ }
35
+
36
+ if (props.flexGrow !== undefined) {
37
+ style.flexGrow = props.flexGrow;
38
+ }
39
+
40
+ if (props.flexShrink !== undefined) {
41
+ style.flexShrink = props.flexShrink;
42
+ }
43
+
44
+ if (props.alignSelf !== undefined) {
45
+ style.alignSelf = props.alignSelf;
46
+ }
47
+
48
+ if (props.order !== undefined) {
49
+ style.order = props.order;
50
+ }
51
+
52
+ return style;
53
+ }
54
+
55
+ /** Dash-named escape-bag keys are attributes; other keys use the DOM property
56
+ * where available. Mirrors ui's web escape-props. */
57
+ function applyEscapeProps(element: any, props: ImageProps): void {
58
+ if (!element || !props.web) {
59
+ return;
60
+ }
61
+
62
+ for (const [key, value] of Object.entries(props.web)) {
63
+ if (key.includes('-')) {
64
+ if (value == null || value === false) {
65
+ element.removeAttribute(key);
66
+ } else {
67
+ element.setAttribute(key, String(value));
68
+ }
69
+ } else if (key in element) {
70
+ element[key] = value;
71
+ } else if (value != null && value !== false) {
72
+ element.setAttribute(key, String(value));
73
+ }
74
+ }
75
+ }
76
+
77
+ /** `blurhash:`/`thumbhash:` decode to a data: PNG (./hash-image — the ui
78
+ * copy); every other value passes through as an ordinary <img> URL (a
79
+ * low-res source URL is a fine placeholder on web). */
80
+ function placeholderUrl(placeholder: string): string {
81
+ const png = hashToPngBase64(placeholder);
82
+ return png ? `data:image/png;base64,${png}` : placeholder;
83
+ }
84
+
85
+ /** Image — a plain <img>, mirroring the core contract: a source array becomes
86
+ * a real `srcset` (browser owns selection); `contentFit`/`contentPosition`
87
+ * map 1:1 to object-fit/object-position with 'cover'/'50% 50%' defaults;
88
+ * `placeholder` rides the same element — the img keeps its last decoded
89
+ * image while a hidden prefetch warms the final src, so placeholder → final
90
+ * swaps in one view without a blank frame. Engine-only props (cachePolicy,
91
+ * failureImage, headers, decodeWidth/decodeHeight, progressive,
92
+ * fadeDuration) have no <img> equivalent and are ignored. onLoad fires
93
+ * without `source` — the browser does not report which cache satisfied the
94
+ * request. */
95
+ export function Image(props: ImageProps) @{
96
+ const imgRef = useRef<HTMLImageElement | null>(null);
97
+ const sources = Array.isArray(props.src) ? (props.src as ImageSourceLike[]) : null;
98
+ const selected = sources
99
+ ? imageSrcSet(sources)
100
+ : { src: props.src as string, srcSet: undefined };
101
+
102
+ useLayoutEffect(() => {
103
+ const img = imgRef.current;
104
+ // complete = the current src already resolved (cache/sync data URI) —
105
+ // showing the placeholder would flash backwards.
106
+ if (!img || !props.placeholder || img.complete) return;
107
+ const placeholder = placeholderUrl(props.placeholder);
108
+ const { src: final, srcSet: finalSrcSet } = selected;
109
+ // (document.createElement — `Image` here is this component, not the
110
+ // global HTMLImageElement constructor)
111
+ const warm = document.createElement('img');
112
+ warm.src = final;
113
+ if (finalSrcSet) {
114
+ warm.srcset = finalSrcSet;
115
+ }
116
+
117
+ const swap = () => {
118
+ img.src = final;
119
+ if (finalSrcSet) {
120
+ // srcset overrides src while present — restore it after the
121
+ // src write so both point at the final selection.
122
+ img.srcset = finalSrcSet;
123
+ }
124
+ };
125
+
126
+ img.addEventListener('load', swap, { once: true });
127
+ img.addEventListener('error', swap, { once: true });
128
+ if (finalSrcSet) {
129
+ // While srcset is set it wins over src — clear it or the
130
+ // placeholder assignment below is inert.
131
+ img.srcset = '';
132
+ }
133
+
134
+ img.src = placeholder;
135
+ return () => {
136
+ img.removeEventListener('load', swap);
137
+ img.removeEventListener('error', swap);
138
+ };
139
+ }, [props.src, props.placeholder]);
140
+
141
+ const style = {
142
+ objectFit: 'cover',
143
+ objectPosition: '50% 50%',
144
+ ...layoutChildStyle(props),
145
+ ...(typeof props.style === 'object' ? props.style : {}),
146
+ ...(props.contentFit !== undefined
147
+ ? { objectFit: resolveContentFit(props.contentFit) }
148
+ : null),
149
+ ...(props.contentPosition !== undefined
150
+ ? { objectPosition: objectPositionCSSValue(resolveContentPosition(props.contentPosition)) }
151
+ : null),
152
+ };
153
+
154
+ <img
155
+ ref={(e: any) => {
156
+ imgRef.current = e;
157
+ applyEscapeProps(e, props);
158
+ }}
159
+ id={props.id}
160
+ className={props.className}
161
+ src={selected.src}
162
+ srcSet={selected.srcSet}
163
+ decoding={props.decoding ?? 'async'}
164
+ alt={props.alt}
165
+ style={style}
166
+ onLoad={(e: any) =>
167
+ props.onLoad?.({
168
+ width: e.currentTarget.naturalWidth ?? 0,
169
+ height: e.currentTarget.naturalHeight ?? 0,
170
+ })
171
+ }
172
+ onError={() =>
173
+ props.onError?.({ error: new Error(`Failed to load image: ${props.src}`) })
174
+ }
175
+ />
176
+ }
@@ -0,0 +1,31 @@
1
+ import type { ImageCacheConfig, ImageCacheQueryOptions, ImageCacheState, PrefetchOptions } from './props';
2
+
3
+ /** No-op — the AppKit host has no image engine. */
4
+ export function initializeImageCache(_config?: ImageCacheConfig): void {}
5
+
6
+ /** No-op — the AppKit host has no image pipeline to warm, so nothing is
7
+ * prefetched; resolves `false` to report that honestly. */
8
+ export function prefetch(_srcs: string | string[], _options?: PrefetchOptions): Promise<boolean> {
9
+ return Promise.resolve(false);
10
+ }
11
+
12
+ export function evictImage(_src: string): Promise<boolean> {
13
+ return Promise.resolve(false);
14
+ }
15
+
16
+ export function clearImageCaches(): Promise<void> {
17
+ return Promise.resolve();
18
+ }
19
+
20
+ export function isImageCached(
21
+ srcs: string | readonly string[],
22
+ _options?: ImageCacheQueryOptions,
23
+ ): Promise<Record<string, ImageCacheState>> {
24
+ const list = typeof srcs === 'string' ? [srcs] : srcs;
25
+ const states: Record<string, ImageCacheState> = {};
26
+ for (const src of list) {
27
+ states[src] = 'none';
28
+ }
29
+
30
+ return Promise.resolve(states);
31
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { ImagePipeline, getImagePipeline, initialize } from '@nativescript-community/ui-image';
2
+ import { prefetchBatch } from './prefetch-batch';
3
+ import type { ImageCacheConfig, ImageCacheQueryOptions, ImageCacheState, PrefetchOptions } from './props';
4
+
5
+ /** Apply engine-level cache configuration. Call from the app entry before the
6
+ * first <Image> renders — the plugin initializes Glide lazily, so sizing must
7
+ * arrive before the first view creates the engine. A later call is a no-op
8
+ * (the plugin guards on its own `initialized` flag). Native only. */
9
+ export function initializeImageCache(config?: ImageCacheConfig): void {
10
+ initialize({
11
+ memoryCacheSize: config?.memoryCacheSize,
12
+ memoryCacheScreens: config?.memoryCacheScreens,
13
+ usePersistentCacheKeyStore: config?.persistentCacheKeys,
14
+ globalSignatureKey: config?.globalSignatureKey,
15
+ });
16
+
17
+ if (config?.iosComplexCacheEviction !== undefined) {
18
+ ImagePipeline.iosComplexCacheEviction = config.iosComplexCacheEviction;
19
+ }
20
+ }
21
+
22
+ /** Warm the disk cache without decoding — bytes are fetched and stored, decode
23
+ * cost stays at display time. One URL or an array; resolves `true` when all
24
+ * warmed, `false` if any URL fails or the pipeline is not up yet (e.g. called
25
+ * before app launch). Same contract as `@octane-xplat/gif`'s `prefetch`.
26
+ *
27
+ * `options.headers` is forwarded on Android and iOS (via the framework
28
+ * patch). `options.concurrency` caps parallel fetches. */
29
+ export function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean> {
30
+ try {
31
+ initialize();
32
+ const pipeline = getImagePipeline();
33
+ const { concurrency, ...engineOptions } = options ?? {};
34
+ return prefetchBatch(srcs, concurrency, (url) =>
35
+ pipeline.prefetchToDiskCache(url, engineOptions).then(
36
+ () => true,
37
+ () => false,
38
+ ),
39
+ );
40
+ } catch {
41
+ return Promise.resolve(false);
42
+ }
43
+ }
44
+
45
+ /** Remove `src` from memory and disk caches. */
46
+ export function evictImage(src: string): Promise<boolean> {
47
+ initialize();
48
+ return getImagePipeline().evictFromCache(src);
49
+ }
50
+
51
+ /** Drop every cached image (memory + disk). */
52
+ export function clearImageCaches(): Promise<void> {
53
+ initialize();
54
+ return getImagePipeline().clearCaches();
55
+ }
56
+
57
+ // The published index.d.ts omits the options params on the two presence
58
+ // probes — the iOS implementation folds them into the cache key through
59
+ // getContextFromOptions (the same context the Img display path builds), and
60
+ // Android ignores the extra arg since its probe keys on the URL alone.
61
+ // Declared locally so the probe can pass the display load's decode context.
62
+ interface CacheProbe {
63
+ isInBitmapMemoryCache(uri: string, options?: ImageCacheQueryOptions): boolean;
64
+ isInDiskCache(uri: string, options?: ImageCacheQueryOptions): Promise<boolean>;
65
+ }
66
+
67
+ /** Report which cache level currently holds each `src` — 'memory', 'disk',
68
+ * or 'none' (not cached / unknown). Accepts a single URL or a list; the
69
+ * result is always a `Record` keyed by URL (the RN `queryCache` shape).
70
+ * `options` repeats the display load's decode bounds: on iOS the cache key
71
+ * includes the transform context, so an image displayed with
72
+ * decodeWidth/decodeHeight only reports its level when the probe passes
73
+ * the same values. Android caveat: the plugin's probe cannot see Glide's
74
+ * active resources, so a bitmap still on screen may report 'none' —
75
+ * treat 'none' as "not provably cached", not "absent". */
76
+ export async function isImageCached(
77
+ srcs: string | readonly string[],
78
+ options?: ImageCacheQueryOptions,
79
+ ): Promise<Record<string, ImageCacheState>> {
80
+ initialize();
81
+ const pipeline = getImagePipeline() as unknown as CacheProbe;
82
+ const list = typeof srcs === 'string' ? [srcs] : srcs;
83
+ const states: Record<string, ImageCacheState> = {};
84
+ await Promise.all(
85
+ list.map(async (src) => {
86
+ states[src] = pipeline.isInBitmapMemoryCache(src, options)
87
+ ? 'memory'
88
+ : ((await pipeline.isInDiskCache(src, options)) ? 'disk' : 'none');
89
+ }),
90
+ );
91
+
92
+ return states;
93
+ }