vireglass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/android/build.gradle +15 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
  7. package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
  8. package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
  9. package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
  10. package/dist/adaptation-BSbbdExK.d.cts +78 -0
  11. package/dist/adaptation-BSbbdExK.d.ts +78 -0
  12. package/dist/adapters-BPB0D5nr.d.cts +357 -0
  13. package/dist/adapters-BgSc-smX.d.ts +357 -0
  14. package/dist/chunk-HF2AWZEZ.js +1741 -0
  15. package/dist/chunk-HF2AWZEZ.js.map +1 -0
  16. package/dist/chunk-JNRZNMJJ.js +20 -0
  17. package/dist/chunk-JNRZNMJJ.js.map +1 -0
  18. package/dist/index.cjs +2159 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +294 -0
  21. package/dist/index.d.ts +294 -0
  22. package/dist/index.js +286 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/material-CkUHUBoy.d.cts +408 -0
  25. package/dist/material-CkUHUBoy.d.ts +408 -0
  26. package/dist/react.cjs +73 -0
  27. package/dist/react.cjs.map +1 -0
  28. package/dist/react.d.cts +25 -0
  29. package/dist/react.d.ts +25 -0
  30. package/dist/react.js +59 -0
  31. package/dist/react.js.map +1 -0
  32. package/dist/web/index.cjs +1990 -0
  33. package/dist/web/index.cjs.map +1 -0
  34. package/dist/web/index.d.cts +148 -0
  35. package/dist/web/index.d.ts +148 -0
  36. package/dist/web/index.js +657 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/expo-module.config.json +6 -0
  39. package/package.json +127 -0
  40. package/src/accessibility.ts +51 -0
  41. package/src/adaptation.ts +85 -0
  42. package/src/adapters.ts +362 -0
  43. package/src/concentric.ts +25 -0
  44. package/src/geometry.ts +151 -0
  45. package/src/glass-scale.ts +37 -0
  46. package/src/group-model.ts +224 -0
  47. package/src/index.ts +17 -0
  48. package/src/lens-shader.ts +547 -0
  49. package/src/material.ts +553 -0
  50. package/src/native/environment.ts +70 -0
  51. package/src/native/glass-group.tsx +107 -0
  52. package/src/native/glass-ink.tsx +44 -0
  53. package/src/native/glass-surface.tsx +477 -0
  54. package/src/native/index.ts +18 -0
  55. package/src/native/lens.ts +106 -0
  56. package/src/native/provider.tsx +58 -0
  57. package/src/native/surface-registry.ts +51 -0
  58. package/src/native/system-accessibility.ts +90 -0
  59. package/src/optics.ts +177 -0
  60. package/src/react.ts +102 -0
  61. package/src/reference-scene.ts +222 -0
  62. package/src/scroll-edge.ts +30 -0
  63. package/src/sdf.ts +149 -0
  64. package/src/surface-shader.ts +287 -0
  65. package/src/targets/agsl.ts +8 -0
  66. package/src/targets/glsl.ts +140 -0
  67. package/src/touch-response.ts +195 -0
  68. package/src/web/gl.ts +119 -0
  69. package/src/web/index.ts +3 -0
  70. package/src/web/probe.ts +312 -0
  71. package/src/web/reference-draw.ts +142 -0
  72. package/src/web/renderer.ts +524 -0
@@ -0,0 +1,107 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { FADE_MS, type BackdropSample } from '../adaptation';
12
+ import { createGroupState, type GroupState } from '../group-model';
13
+
14
+ /**
15
+ * SURFACE GROUP — a block of glass that adapts as one.
16
+ *
17
+ * Each lens sees its own patch of backdrop, and by its own measurement one nav button can drop
18
+ * into shadow while its neighbor stays transparent; over a busy backdrop their ink polarity
19
+ * splits apart too. The block then stops reading as a block and falls apart into separate
20
+ * elements.
21
+ *
22
+ * The group collects members' measurements and hands everyone back ONE ink polarity.
23
+ *
24
+ * It no longer hands out an AMBIENT estimate: that now lives in the lens itself, as on the web —
25
+ * the shared one used to override each element's own probe, and under one material the nav bar
26
+ * and the feed screen ended up adapting to the backdrop differently. The pull bus went with it:
27
+ * what deforms is the field around the touch point, not the element (`createDeform` in the
28
+ * core), and there's nothing left to merge neighboring surfaces into one drop with.
29
+ */
30
+ type GroupApi = {
31
+ report: (
32
+ id: string,
33
+ x: number,
34
+ y: number,
35
+ sample: BackdropSample,
36
+ legibility: number,
37
+ ) => void;
38
+ /** Take a member off the books on unmount: otherwise it stays in the block's estimate forever. */
39
+ release: (id: string) => void;
40
+ ink: number | undefined;
41
+ };
42
+
43
+ const GlassGroupContext = createContext<GroupApi | null>(null);
44
+
45
+ export function GlassGroup({ children }: { children: ReactNode }) {
46
+ const [ink, setInk] = useState(1);
47
+
48
+ const target = useRef(1);
49
+ const current = useRef(1);
50
+ const from = useRef(1);
51
+ const startedAt = useRef(0);
52
+ const raf = useRef<ReturnType<typeof requestAnimationFrame> | null>(null);
53
+
54
+ const animate = useCallback(() => {
55
+ const step = () => {
56
+ const t = Math.min((Date.now() - startedAt.current) / FADE_MS, 1);
57
+ const e = 0.5 - 0.5 * Math.cos(Math.PI * t);
58
+ current.current = from.current + (target.current - from.current) * e;
59
+ setInk(current.current);
60
+ raf.current = t < 1 ? requestAnimationFrame(step) : null;
61
+ };
62
+ if (raf.current !== null) cancelAnimationFrame(raf.current);
63
+ raf.current = requestAnimationFrame(step);
64
+ }, []);
65
+
66
+ useEffect(() => () => {
67
+ if (raf.current !== null) cancelAnimationFrame(raf.current);
68
+ }, []);
69
+
70
+ const flip = useCallback(
71
+ (polarity: number) => {
72
+ from.current = current.current;
73
+ target.current = polarity;
74
+ startedAt.current = Date.now();
75
+ animate();
76
+ },
77
+ [animate],
78
+ );
79
+ // The block's state is created once, while the callback is rebuilt on every render — so what
80
+ // goes into the state is a reference to it, not the callback itself.
81
+ const flipRef = useRef(flip);
82
+ flipRef.current = flip;
83
+
84
+ const state = useRef<GroupState | null>(null);
85
+ if (state.current === null) {
86
+ // The core still computes the block's lightness plane, but nothing reads it anymore: each
87
+ // lens estimates its own ambient now. What's left here is ink polarity.
88
+ state.current = createGroupState({
89
+ plane: () => {},
90
+ flip: (polarity) => flipRef.current(polarity),
91
+ });
92
+ }
93
+ const group = state.current;
94
+
95
+ const api = useMemo<GroupApi>(
96
+ () => ({ report: group.report, release: group.release, ink }),
97
+ [group, ink],
98
+ );
99
+
100
+ return <GlassGroupContext.Provider value={api}>{children}</GlassGroupContext.Provider>;
101
+ }
102
+
103
+ /** Inside a group, a surface hands it a measurement and gets back the shared estimate. Outside a
104
+ * group — null. */
105
+ export function useGlassGroup(): GroupApi | null {
106
+ return useContext(GlassGroupContext);
107
+ }
@@ -0,0 +1,44 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import { INK_DARK, INK_LIGHT } from '../adaptation';
3
+
4
+ /**
5
+ * Ink polarity for content ON TOP of the glass, handed down to the surface's children.
6
+ *
7
+ * The glass itself decides whether letters over it must be light or dark: it's the only thing
8
+ * that sees what's underneath (`useGlassAdaptation`). Children just ask for the color —
9
+ * otherwise every screen would grow its own logic and they'd drift apart from each other.
10
+ *
11
+ * The value is continuous: on a recolor it travels between the ends, and the color travels
12
+ * along with it.
13
+ */
14
+ const GlassInkContext = createContext(1);
15
+
16
+ export function GlassInkProvider({ ink, children }: { ink: number; children: ReactNode }) {
17
+ return <GlassInkContext.Provider value={ink}>{children}</GlassInkContext.Provider>;
18
+ }
19
+
20
+ /** Polarity: 1 — light ink, 0 — dark. */
21
+ export function useGlassInk(): number {
22
+ return useContext(GlassInkContext);
23
+ }
24
+
25
+ const hex = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255);
26
+
27
+ /**
28
+ * Ink color for the current polarity. Between the ends it travels by lightness — the same
29
+ * quantity the glass uses for its own separation, so the text and the body don't drift apart
30
+ * mid-transition.
31
+ */
32
+ export function inkColor(ink: number, light: string, dark: string): string {
33
+ if (ink >= 0.999) return light;
34
+ if (ink <= 0.001) return dark;
35
+ // In-between frames last a fraction of a second, and exact per-channel interpolation isn't
36
+ // needed here: what matters is that the transition doesn't read as a step.
37
+ const v = INK_DARK + (INK_LIGHT - INK_DARK) * ink;
38
+ const g = hex(v);
39
+ return `rgb(${g}, ${g}, ${g})`;
40
+ }
41
+
42
+ export function useInkColor(light: string, dark: string): string {
43
+ return inkColor(useGlassInk(), light, dark);
44
+ }
@@ -0,0 +1,477 @@
1
+ import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
2
+ import {
3
+ findNodeHandle,
4
+ PixelRatio,
5
+ Platform,
6
+ StyleSheet,
7
+ View,
8
+ type StyleProp,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import {
12
+ Canvas,
13
+ ColorShader,
14
+ Fill,
15
+ ImageShader,
16
+ Shader,
17
+ Skia,
18
+ type SkImage,
19
+ } from '@shopify/react-native-skia';
20
+ import Animated, {
21
+ useAnimatedProps,
22
+ useAnimatedStyle,
23
+ useDerivedValue,
24
+ type SharedValue,
25
+ } from 'react-native-reanimated';
26
+ import { BlurView } from 'expo-blur';
27
+ import { GlassLens as GlassLensNative, isGlassLensSupported } from './lens';
28
+ import {
29
+ lensMagnify,
30
+ toLensProps,
31
+ toSurfaceUniforms,
32
+ type VireGlassMorph,
33
+ } from '../adapters';
34
+ import {
35
+ lensPadDp,
36
+ shadowOpacityFrom,
37
+ surfacePadDp,
38
+ type VireGlassGeometry,
39
+ } from '../geometry';
40
+ import {
41
+ ambientFrom,
42
+ type BackdropSample,
43
+ } from '../adaptation';
44
+ import type { DeformSample } from '../touch-response';
45
+ import type { VireGlassDebugMode, VireGlassOptics } from '../material';
46
+ import { LENS_SHADER } from '../lens-shader';
47
+ import { SURFACE_SHADER } from '../surface-shader';
48
+ import { applyAccessibility } from '../accessibility';
49
+ import { useAccessibilityModifiers, useBackdropEnabled } from './provider';
50
+ import { useGlassSurfaceRegistration } from './surface-registry';
51
+
52
+ function compile(src: string) {
53
+ const effect = Skia.RuntimeEffect.Make(src);
54
+ if (!effect) throw new Error('VireGlass: surface SKSL failed to compile');
55
+ return effect;
56
+ }
57
+
58
+ /** The fraction of activity that a touch alone raises, matching the same fraction the web
59
+ * implementation's own button logic uses. */
60
+ const ACTIVE_ON_TOUCH = 0.3;
61
+
62
+ const SURFACE = compile(SURFACE_SHADER);
63
+
64
+ /** The lens takes pull as an animated prop: it and the surface must bend in the SAME frame.
65
+ * Through an ordinary prop the value would travel from the JS thread while the surface travels
66
+ * from the UI thread, and the layers would drift apart exactly the way they once did with the
67
+ * transform. The wrapper is created once: `createAnimatedComponent` inside render would
68
+ * recreate the type and drop the view every frame. */
69
+ const AnimatedGlassLens = GlassLensNative
70
+ ? Animated.createAnimatedComponent(GlassLensNative)
71
+ : null;
72
+
73
+ export type GlassDynamics = {
74
+ shiftX: SharedValue<number>;
75
+ shiftY: SharedValue<number>;
76
+ press: SharedValue<number>;
77
+ active: SharedValue<number>;
78
+ /** Key light direction in screen coordinates (unit vector). */
79
+ light: SharedValue<readonly number[]>;
80
+ };
81
+
82
+ export type GlassIcon = {
83
+ image: SkImage | null;
84
+ /** Mask scale: it's built in device pixels, and mushes on 3x screens at dp. */
85
+ scale: number;
86
+ inkIdle: number[];
87
+ inkActive: number[];
88
+ /** A colored layer ON the glass: the currently playing track's cover art, same idea as the web
89
+ * mini-player. The mask is single-channel and colored by polarity, while this layer carries
90
+ * its own color as-is. It shares the mask's box: the shader samples both at the same `inkUv`. */
91
+ overlay?: SkImage | null;
92
+ };
93
+
94
+ type GlassBackdropRead = { ambient: [number, number, number]; shadow: number };
95
+
96
+ export function VireGlassSurface({
97
+ geometry,
98
+ optics,
99
+ dynamics,
100
+ debug = 'normal',
101
+ morph,
102
+ blurTarget,
103
+ backdrop = true,
104
+ shadow,
105
+ dragLimit = 0,
106
+ icon,
107
+ progress,
108
+ touch,
109
+ appear,
110
+ lift = 0,
111
+ dim = 0,
112
+ topLayer = false,
113
+ onBackdropSample,
114
+ style,
115
+ }: {
116
+ geometry: VireGlassGeometry;
117
+ optics: VireGlassOptics;
118
+ dynamics: GlassDynamics;
119
+ debug?: VireGlassDebugMode;
120
+ morph?: VireGlassMorph;
121
+ /** Target of the live blur — the current screen's own content view. */
122
+ blurTarget?: RefObject<View | null> | null;
123
+ /** Turning this off mounts the surface with no backdrop: a reference point for comparing
124
+ * against it in the lab. */
125
+ backdrop?: boolean;
126
+ /** Manual shadow density. Without it, the probe drives it from what's under the element. */
127
+ shadow?: number;
128
+ dragLimit?: number;
129
+ icon?: GlassIcon;
130
+ /** Played fraction, 0…1: to the left of the boundary the element is active, to the right it
131
+ * isn't. Travels as a shared value — through an ordinary prop it would rebuild the whole
132
+ * uniform channel and overwrite the pull drop the worklet had already substituted in, exactly
133
+ * as it once did with the static `uniformValues`. */
134
+ progress?: SharedValue<number>;
135
+ /** Response to a finger BY THE CORE MODEL (`createDeform`): touch point, pull around the spot,
136
+ * the dent, and the wave. This didn't exist here at all before — the shader got `NO_TOUCH`,
137
+ * i.e. zeros, and no deformation existed for any gesture. The web runs the exact same fields
138
+ * (`web/renderer.ts`); the adapter contract is shared and specified in dp. */
139
+ touch?: SharedValue<DeformSample>;
140
+ /** Fraction in which the element EXISTS, 0…1: the glass builds up rather than fading in
141
+ * through opacity (reference §12). Travels as a shared value for the same reason as
142
+ * `progress`. */
143
+ appear?: SharedValue<number>;
144
+ /** 0 — under the finger the element sinks in, 1 — it rises into glass and its shadow pulls
145
+ * back (reference §5). A property of the element itself, not of its motion, so an ordinary
146
+ * prop. */
147
+ lift?: number;
148
+ /** Lightness of the backdrop UNDER the glass, about every 200 ms. This is how the screen finds
149
+ * out the glass has hit its own limit and it's time to recolor the ink (`adaptation.ts`). */
150
+ onBackdropSample?: (e: { nativeEvent: BackdropSample }) => void;
151
+ /** Dimming of the lens under the screen's scrim: BlurView targets the content directly and
152
+ * can't see a darkening overlay drawn above it — without this the lens glows through the
153
+ * scrim like a hole. */
154
+ dim?: number;
155
+ /** Top-layer surface: not muted by the screen's own overlay. */
156
+ topLayer?: boolean;
157
+ style?: StyleProp<ViewStyle>;
158
+ }) {
159
+ const { width, height, cornerRadius } = geometry;
160
+ const pad = surfacePadDp(geometry, dragLimit, morph);
161
+
162
+ // The lens view must not change size on the fly: every change is a relayout plus a new
163
+ // `RenderEffect`, and to the eye it reads as a jolt. Morphing, though, moves the second shape
164
+ // every frame, meaning the required margin changes continuously. So the margin only GROWS: it
165
+ // reaches its maximum on the first pass, and stays fixed from then on. It only resets when the
166
+ // element's own size changes.
167
+ const padRef = useRef(0);
168
+ const geometryKey = `${width}x${height}x${cornerRadius}`;
169
+ const geometryRef = useRef(geometryKey);
170
+ if (geometryRef.current !== geometryKey) {
171
+ geometryRef.current = geometryKey;
172
+ padRef.current = 0;
173
+ }
174
+ // The probe already computes both the ambient color (it bleeds into the shadow, reference §7)
175
+ // and the variegation (shadow density follows it, 219 @11:47) — the only job left is not to
176
+ // lose them along the way. The subscription sits right where the measurement already happens:
177
+ // glass with a hand-pinned polarity has no probe, and its shadow doesn't adapt.
178
+ const [sampled, setSampled] = useState<GlassBackdropRead | undefined>(undefined);
179
+ const handleSample = useMemo(() => {
180
+ if (!onBackdropSample) return undefined;
181
+ return (event: { nativeEvent: BackdropSample }) => {
182
+ const ambient = ambientFrom(event.nativeEvent);
183
+ const shade = shadowOpacityFrom(event.nativeEvent);
184
+ setSampled((prev) =>
185
+ prev &&
186
+ prev.shadow === shade &&
187
+ prev.ambient[0] === ambient[0] &&
188
+ prev.ambient[1] === ambient[1] &&
189
+ prev.ambient[2] === ambient[2]
190
+ ? prev
191
+ : { ambient, shadow: shade },
192
+ );
193
+ onBackdropSample(event);
194
+ };
195
+ }, [onBackdropSample]);
196
+
197
+ // System settings change the material's LAYERS, they don't override it (reference §9). Applied
198
+ // right here: every piece of the app's glass passes through this surface, and one place is
199
+ // enough.
200
+ const a11y = useAccessibilityModifiers();
201
+ const tuned = useMemo(() => applyAccessibility(optics, a11y), [optics, a11y]);
202
+
203
+ padRef.current = Math.max(padRef.current, lensPadDp(geometry, tuned, morph, dragLimit));
204
+ const lensPad = padRef.current;
205
+
206
+ // The blur target is a ref, and it's still empty on the first render: by itself it doesn't
207
+ // trigger a repaint. Without this effect the glass stays without a backdrop until the first
208
+ // unrelated re-render — forever, on a static screen.
209
+ const [hasTarget, setHasTarget] = useState(false);
210
+ // The native lens needs the target's TAG: it uses it to find its own capture inside it.
211
+ const [backdropId, setBackdropId] = useState<number | null>(null);
212
+ useEffect(() => {
213
+ const node = blurTarget?.current ?? null;
214
+ setHasTarget(node != null);
215
+ setBackdropId(node ? findNodeHandle(node) : null);
216
+ }, [blurTarget]);
217
+
218
+ // The single point where whether the backdrop lives gets decided. ALL of the app's glass
219
+ // passes through it, so both the settings toggle and suppression under an open sheet sit here
220
+ // rather than being smeared across consumers. Suppression under a sheet is mandated by the kit
221
+ // itself: the layers below the scrim have nothing left to refract, and it's also what keeps
222
+ // the surface count within the green zone (`surface-registry.ts`).
223
+ const backdropAllowed = useBackdropEnabled(topLayer);
224
+ const liveBackdrop = backdrop && backdropAllowed;
225
+ const refracting = isGlassLensSupported && GlassLensNative !== null;
226
+ const backdropReady = liveBackdrop && hasTarget && blurTarget?.current != null;
227
+ // The view with the probe below is mounted by this same expression — the two conditions have
228
+ // nothing to diverge on. And there is something for them to diverge on: the lens goes away
229
+ // both under an open sheet and under reduced transparency, while the shadow always renders,
230
+ // and the color of a backdrop that's gone would otherwise freeze in it.
231
+ const measuring = backdropReady && refracting && AnimatedGlassLens !== null;
232
+ const read = measuring ? sampled : undefined;
233
+ const ambient = read?.ambient;
234
+ // Shadow density follows what's UNDER THE ELEMENT (219 @11:47), computed by the same probe as
235
+ // the color.
236
+ const shade = shadow ?? read?.shadow ?? 1;
237
+
238
+ // The lens draws the glass body when it's live: only there is the backdrop visible, and
239
+ // without a backdrop there's no such thing as point-wise adaptation. The surface is then left
240
+ // with the highlight, the shadow, and the icon.
241
+ const bodyInLens = isGlassLensSupported && GlassLensNative !== null && hasTarget;
242
+ const statics = useMemo(
243
+ () => toSurfaceUniforms(tuned, geometry, { debug, morph, dragLimit, shadow: shade, bodyInLens, ambient, lift }),
244
+ [tuned, geometry, debug, morph, dragLimit, shade, bodyInLens, ambient, lift],
245
+ );
246
+ // The shader source is part of the result, so it's in the dependency list. Formally it's a
247
+ // module constant, but hot reload changes it, and a memo with the old dependencies keeps
248
+ // handing back the OLD shader: an optics edit silently fails to arrive.
249
+ const lensProps = useMemo(
250
+ // The backdrop estimate is the lens's OWN probe, as on the web. A group estimate used to
251
+ // override it and was an Android-only invention: on the web there's no `groupProbe` at all —
252
+ // there, every element adapts by its own probe. Because of that override, the nav bar and
253
+ // the feed screen adapted to the surroundings differently under one and the same material.
254
+ () => toLensProps(tuned, geometry, PixelRatio.get(), { debug, morph }),
255
+ [tuned, geometry, debug, morph, LENS_SHADER],
256
+ );
257
+ const iconUniforms = useMemo(
258
+ () => ({
259
+ u_iconOn: icon?.image ? 1 : 0,
260
+ u_iconScale: icon?.scale ?? 1,
261
+ // The shader slot must always be occupied: Skia hands out child shaders in declaration
262
+ // order, and an empty slot would shift the ink mask.
263
+ u_overlayOn: icon?.overlay ? 1 : 0,
264
+ u_inkIdle: icon?.inkIdle ?? [1, 1, 1, 1],
265
+ u_inkActive: icon?.inkActive ?? [1, 1, 1, 1],
266
+ }),
267
+ [icon],
268
+ );
269
+
270
+ const { press, active, light } = dynamics;
271
+
272
+ const halfMin = Math.min(geometry.width, geometry.height) / 2;
273
+ /** A finger is an area, not a point. The radius is taken from the SMALLER half-size: otherwise
274
+ * on a wide plate the touch would spread across its whole length. Same fraction the web
275
+ * implementation uses for its own touch handling. */
276
+ const touchRadius = 0.72 * halfMin;
277
+ // The lens channel is in pixels, while the whole model's geometry is in dp. Read once:
278
+ // `PixelRatio` isn't available inside a worklet.
279
+ const density = PixelRatio.get();
280
+
281
+ // Where each value sits in the lens's flat uniform channel. Names and sizes are fixed, so the
282
+ // offsets are computed once, leaving the worklet to just plug in the numbers.
283
+ const slots = useMemo(() => {
284
+ const at: Record<string, number> = {};
285
+ let i = 0;
286
+ for (let k = 0; k < lensProps.uniformNames.length; k += 1) {
287
+ at[lensProps.uniformNames[k]] = i;
288
+ i += lensProps.uniformSizes[k];
289
+ }
290
+ return {
291
+ progress: at.u_progress ?? -1,
292
+ touch: at.u_touch ?? -1,
293
+ pull: at.u_pull ?? -1,
294
+ touchPress: at.u_touchPress ?? -1,
295
+ touchRadius: at.u_touchRadius ?? -1,
296
+ wave: at.u_wave ?? -1,
297
+ light: at.u_light ?? -1,
298
+ };
299
+ }, [lensProps]);
300
+
301
+ // Uniform values travel ONLY through the animated prop. While they still also went through the
302
+ // ordinary one, with an active group (which repaints the block a couple dozen times a second)
303
+ // the ordinary prop kept overwriting the drop the worklet had substituted in, and on a given
304
+ // frame it simply wasn't there.
305
+ const { uniformValues: _values, ...lensStatic } = lensProps;
306
+
307
+ const lensAnimatedProps = useAnimatedProps<{ uniformValues: number[] }>(() => {
308
+ const values = lensProps.uniformValues.slice();
309
+ if (slots.progress >= 0 && progress) values[slots.progress] = progress.value;
310
+ // The lens computes the rim light, while device tilt arrives via the worklet — into the
311
+ // same slot.
312
+ if (slots.light >= 0) {
313
+ values[slots.light] = light.value[0];
314
+ values[slots.light + 1] = light.value[1];
315
+ }
316
+ // Response to a finger. The lens channel is in PIXELS while the model is in dp: geometric
317
+ // fields get multiplied by density, the wave phase and the dent are dimensionless. The web
318
+ // does the same conversion (`web/renderer.ts`); the adapter contract can't be touched — it's
319
+ // shared.
320
+ if (touch) {
321
+ const t = touch.value;
322
+ if (slots.touch >= 0) {
323
+ values[slots.touch] = t.touchX * density;
324
+ values[slots.touch + 1] = t.touchY * density;
325
+ }
326
+ if (slots.pull >= 0) {
327
+ values[slots.pull] = t.pullX * density;
328
+ values[slots.pull + 1] = t.pullY * density;
329
+ }
330
+ if (slots.touchPress >= 0) values[slots.touchPress] = t.press;
331
+ if (slots.touchRadius >= 0) values[slots.touchRadius] = touchRadius * density;
332
+ if (slots.wave >= 0) {
333
+ values[slots.wave] = t.waveAmp * density;
334
+ values[slots.wave + 1] = t.wavePhase;
335
+ }
336
+ }
337
+ return { uniformValues: values };
338
+ }, [lensProps, slots, density, progress, touch, touchRadius, light]);
339
+
340
+ const uniforms = useDerivedValue(() => {
341
+ return {
342
+ ...statics,
343
+ ...iconUniforms,
344
+ // Response to a finger by the core model. Nothing used to arrive here at all, and the
345
+ // shader ran on `NO_TOUCH` — zeros: no touch point, no pull, no wave existed.
346
+ u_touch: touch ? [touch.value.touchX, touch.value.touchY] : statics.u_touch,
347
+ u_pull: touch ? [touch.value.pullX, touch.value.pullY] : statics.u_pull,
348
+ u_touchPress: touch ? touch.value.press : statics.u_touchPress,
349
+ u_touchRadius: touch ? touchRadius : statics.u_touchRadius,
350
+ u_wave: touch ? [touch.value.waveAmp, touch.value.wavePhase] : statics.u_wave,
351
+ u_press: touch ? touch.value.press : press.value,
352
+ // Touch and activity feed one uniform: the stronger of the two wins, otherwise pressing an
353
+ // already-active element would read as switching it off. Same fraction as the web
354
+ // implementation's own button logic: a touch raises activity by a third, not to the full
355
+ // value.
356
+ u_active: touch ? Math.max(touch.value.active * ACTIVE_ON_TOUCH, active.value) : active.value,
357
+ u_progress: progress ? progress.value : statics.u_progress,
358
+ // The element builds up as glass, not through opacity: zero means it doesn't exist at all.
359
+ u_appear: appear ? appear.value : statics.u_appear,
360
+ };
361
+ }, [statics, iconUniforms, progress, touch, touchRadius, appear]);
362
+
363
+ const magnify = lensMagnify(tuned);
364
+
365
+ // Guards the ACTUAL count of live surfaces; a consuming app's own test guards the declared
366
+ // model.
367
+ useGlassSurfaceRegistration(liveBackdrop && hasTarget);
368
+
369
+ return (
370
+ <View collapsable={false} style={[styles.host, style, { width, height }]}>
371
+ {/* The kit's "reduced" state: glass is off — the panel is opaque, blur is removed. */}
372
+ {!backdropAllowed && (
373
+ <View
374
+ style={[styles.opaque, { width, height, borderRadius: cornerRadius }]}
375
+ pointerEvents="none"
376
+ />
377
+ )}
378
+ {/* A screenshot (makeImageFromView) is a non-starter for the backdrop in principle: about
379
+ 1000 ms per frame, and any refraction against it would lag. BlurView with blurTarget
380
+ draws the screen's content natively, frame by frame, and is aligned with it by
381
+ construction. */}
382
+ {/* collapsable={false} is required on both: the static style has no transform, it only
383
+ arrives from the worklet, and Android RN treats such a node as redundant and collapses
384
+ it into its parent — the deformation then has simply nowhere to apply. */}
385
+ <View
386
+ style={[styles.moving, { width, height }]}
387
+ pointerEvents="none"
388
+ collapsable={false}
389
+ >
390
+ <View style={[styles.lens, { width, height }]}>
391
+ {backdropReady ? (
392
+ measuring && AnimatedGlassLens ? (
393
+ // The lens view is DELIBERATELY bigger than the glass — at the rim the sample runs
394
+ // past its bounds, and the shader itself carves out the shape.
395
+ <AnimatedGlassLens
396
+ {...lensStatic}
397
+ animatedProps={lensAnimatedProps}
398
+ backdropId={backdropId}
399
+ onBackdropSample={handleSample}
400
+ style={{
401
+ position: 'absolute',
402
+ width: width + lensPad * 2,
403
+ height: height + lensPad * 2,
404
+ }}
405
+ />
406
+ ) : (
407
+ // Fallback below Android 13: uniform magnification. This is a magnifying glass, not
408
+ // a lens — an affine transform can't express a radially varying shift.
409
+ <View style={[styles.clip, { width, height, borderRadius: cornerRadius }]}>
410
+ <View
411
+ style={{
412
+ position: 'absolute',
413
+ left: (width * (1 - 1 / magnify)) / 2,
414
+ top: (height * (1 - 1 / magnify)) / 2,
415
+ width: width / magnify,
416
+ height: height / magnify,
417
+ transform: [{ scale: magnify }],
418
+ }}
419
+ >
420
+ {/* The fallback has no shader — there's nothing but BlurView left to blur with. */}
421
+ <BlurView
422
+ intensity={tuned.blur}
423
+ tint="dark"
424
+ blurMethod={Platform.OS === 'android' ? 'dimezisBlurView' : undefined}
425
+ blurTarget={blurTarget}
426
+ style={StyleSheet.absoluteFill}
427
+ />
428
+ </View>
429
+ </View>
430
+ )
431
+ ) : null}
432
+ {dim > 0 ? (
433
+ <View
434
+ style={[styles.scrim, { width, height, borderRadius: cornerRadius, opacity: dim }]}
435
+ />
436
+ ) : null}
437
+ </View>
438
+ <Canvas
439
+ style={[
440
+ styles.canvas,
441
+ { width: width + pad * 2, height: height + pad * 2, left: -pad, top: -pad },
442
+ ]}
443
+ >
444
+ {/* dither is OFF: Skia mixes it in while rasterizing to an 8-bit surface, and on dark
445
+ content that reads as grain. Measured in the lab: with the backdrop off, leaving only
446
+ this layer, grain inside comes to 3.5 against 0.04 outside. */}
447
+ <Fill dither={false}>
448
+ <Shader source={SURFACE} uniforms={uniforms}>
449
+ {icon?.image ? (
450
+ <ImageShader image={icon.image} tx="decal" ty="decal" />
451
+ ) : (
452
+ <ColorShader color="#00000000" />
453
+ )}
454
+ {/* Second slot — the colored layer (u_overlay): the mini-player's cover art. */}
455
+ {icon?.overlay ? (
456
+ <ImageShader image={icon.overlay} tx="decal" ty="decal" />
457
+ ) : (
458
+ <ColorShader color="#00000000" />
459
+ )}
460
+ </Shader>
461
+ </Fill>
462
+ </Canvas>
463
+ </View>
464
+ </View>
465
+ );
466
+ }
467
+
468
+ const styles = StyleSheet.create({
469
+ host: { overflow: 'visible' },
470
+ moving: { position: 'absolute' },
471
+ lens: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
472
+ clip: { position: 'absolute', overflow: 'hidden' },
473
+ canvas: { position: 'absolute' },
474
+ scrim: { position: 'absolute', backgroundColor: '#0d0b09' },
475
+ // Opaque backdrop for the "glass off" state — color from the kit (§02, the reduced state).
476
+ opaque: { position: 'absolute', backgroundColor: '#0b0908' },
477
+ });
@@ -0,0 +1,18 @@
1
+ export { VireGlassSurface } from './glass-surface';
2
+ export type { GlassDynamics, GlassIcon } from './glass-surface';
3
+
4
+ export { GlassGroup, useGlassGroup } from './glass-group';
5
+
6
+ export { GlassInkProvider, useGlassInk, useInkColor, inkColor } from './glass-ink';
7
+
8
+ export { VireGlassProvider, useAccessibilityModifiers, useBackdropEnabled } from './provider';
9
+
10
+ export { GlassLens, GlassBackdrop, GlassProbe, isGlassLensSupported } from './lens';
11
+ export type { GlassLensProps, GlassBackdropProps, GlassProbeProps } from './lens';
12
+
13
+ export { useEnvironmentLight } from './environment';
14
+
15
+ export { glassSurfaceStats, useGlassSurfaceRegistration, GLASS_GREEN_MAX } from './surface-registry';
16
+
17
+ export { useSystemAccessibility, mergeAccessibility } from './system-accessibility';
18
+ export type { SystemAccessibility } from './system-accessibility';