@rafaelaugusto/jolt 0.1.0-beta.12 → 0.1.0-beta.14

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 (151) hide show
  1. package/README.md +9 -6
  2. package/dist/animate.d.ts +6 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +3 -1
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.js +3 -3
  7. package/dist/drag.js.map +1 -1
  8. package/dist/driver/android-gradient.d.ts.map +1 -1
  9. package/dist/driver/android-gradient.js +11 -1
  10. package/dist/driver/android-gradient.js.map +1 -1
  11. package/dist/driver/android-particles.d.ts +6 -0
  12. package/dist/driver/android-particles.d.ts.map +1 -0
  13. package/dist/driver/android-particles.js +93 -0
  14. package/dist/driver/android-particles.js.map +1 -0
  15. package/dist/driver/android.d.ts.map +1 -1
  16. package/dist/driver/android.js +2 -0
  17. package/dist/driver/android.js.map +1 -1
  18. package/dist/driver/frame.d.ts +3 -0
  19. package/dist/driver/frame.d.ts.map +1 -0
  20. package/dist/driver/frame.js +7 -0
  21. package/dist/driver/frame.js.map +1 -0
  22. package/dist/driver/index.d.ts +2 -0
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-gradient.d.ts.map +1 -1
  26. package/dist/driver/ios-gradient.js +24 -2
  27. package/dist/driver/ios-gradient.js.map +1 -1
  28. package/dist/driver/ios.d.ts.map +1 -1
  29. package/dist/driver/ios.js +2 -0
  30. package/dist/driver/ios.js.map +1 -1
  31. package/dist/engine.d.ts +2 -1
  32. package/dist/engine.d.ts.map +1 -1
  33. package/dist/engine.js +32 -5
  34. package/dist/engine.js.map +1 -1
  35. package/dist/gradient-binding.d.ts +38 -0
  36. package/dist/gradient-binding.d.ts.map +1 -0
  37. package/dist/gradient-binding.js +128 -0
  38. package/dist/gradient-binding.js.map +1 -0
  39. package/dist/gradient.d.ts +19 -4
  40. package/dist/gradient.d.ts.map +1 -1
  41. package/dist/gradient.js +331 -70
  42. package/dist/gradient.js.map +1 -1
  43. package/dist/haptics.d.ts.map +1 -1
  44. package/dist/haptics.js +101 -28
  45. package/dist/haptics.js.map +1 -1
  46. package/dist/image-effect.d.ts +1 -0
  47. package/dist/image-effect.d.ts.map +1 -1
  48. package/dist/image-effect.js +10 -2
  49. package/dist/image-effect.js.map +1 -1
  50. package/dist/index.d.ts +6 -2
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/motion-scope.d.ts +3 -2
  55. package/dist/motion-scope.d.ts.map +1 -1
  56. package/dist/motion-scope.js +12 -2
  57. package/dist/motion-scope.js.map +1 -1
  58. package/dist/motion-value.d.ts +62 -8
  59. package/dist/motion-value.d.ts.map +1 -1
  60. package/dist/motion-value.js +327 -91
  61. package/dist/motion-value.js.map +1 -1
  62. package/dist/native/index.d.ts +7 -0
  63. package/dist/native/index.d.ts.map +1 -0
  64. package/dist/native/index.js +4 -0
  65. package/dist/native/index.js.map +1 -0
  66. package/dist/native-properties.d.ts +28 -0
  67. package/dist/native-properties.d.ts.map +1 -0
  68. package/dist/native-properties.js +154 -0
  69. package/dist/native-properties.js.map +1 -0
  70. package/dist/native-property-driver.d.ts +19 -0
  71. package/dist/native-property-driver.d.ts.map +1 -0
  72. package/dist/native-property-driver.js +69 -0
  73. package/dist/native-property-driver.js.map +1 -0
  74. package/dist/native-property.d.ts +35 -0
  75. package/dist/native-property.d.ts.map +1 -0
  76. package/dist/native-property.js +277 -0
  77. package/dist/native-property.js.map +1 -0
  78. package/dist/particles.d.ts +5 -2
  79. package/dist/particles.d.ts.map +1 -1
  80. package/dist/particles.js +16 -10
  81. package/dist/particles.js.map +1 -1
  82. package/dist/playback-group.d.ts.map +1 -1
  83. package/dist/playback-group.js +17 -1
  84. package/dist/playback-group.js.map +1 -1
  85. package/dist/playback.d.ts +15 -0
  86. package/dist/playback.d.ts.map +1 -1
  87. package/dist/playback.js +23 -0
  88. package/dist/playback.js.map +1 -1
  89. package/dist/scene-appearance.d.ts +14 -0
  90. package/dist/scene-appearance.d.ts.map +1 -0
  91. package/dist/scene-appearance.js +68 -0
  92. package/dist/scene-appearance.js.map +1 -0
  93. package/dist/sequence.d.ts +24 -0
  94. package/dist/sequence.d.ts.map +1 -1
  95. package/dist/sequence.js +181 -19
  96. package/dist/sequence.js.map +1 -1
  97. package/dist/timed-animation.d.ts +38 -0
  98. package/dist/timed-animation.d.ts.map +1 -0
  99. package/dist/timed-animation.js +177 -0
  100. package/dist/timed-animation.js.map +1 -0
  101. package/dist/types.d.ts +1 -1
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/value-observers.d.ts +19 -0
  104. package/dist/value-observers.d.ts.map +1 -0
  105. package/dist/value-observers.js +111 -0
  106. package/dist/value-observers.js.map +1 -0
  107. package/dist/value-track.d.ts +13 -0
  108. package/dist/value-track.d.ts.map +1 -0
  109. package/dist/value-track.js +59 -0
  110. package/dist/value-track.js.map +1 -0
  111. package/dist/vue/index.d.ts.map +1 -1
  112. package/dist/vue/index.js +28 -5
  113. package/dist/vue/index.js.map +1 -1
  114. package/package.json +6 -2
  115. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -1
  116. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleEmitter.java +192 -0
  117. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleSimulation.java +114 -0
  118. package/platforms/android/proguard-rules.pro +2 -0
  119. package/src/android-plugin.d.ts +93 -0
  120. package/src/animate.ts +13 -7
  121. package/src/drag.ts +3 -3
  122. package/src/driver/android-gradient.ts +8 -1
  123. package/src/driver/android-particles.ts +118 -0
  124. package/src/driver/android.ts +2 -0
  125. package/src/driver/frame.ts +10 -0
  126. package/src/driver/index.ts +2 -0
  127. package/src/driver/ios-gradient.ts +24 -2
  128. package/src/driver/ios.ts +2 -0
  129. package/src/engine.ts +33 -6
  130. package/src/gradient-binding.ts +158 -0
  131. package/src/gradient.ts +376 -91
  132. package/src/haptics.ts +106 -28
  133. package/src/image-effect.ts +10 -2
  134. package/src/index.ts +6 -0
  135. package/src/ios-plugin.d.ts +40 -0
  136. package/src/motion-scope.ts +14 -3
  137. package/src/motion-value.ts +374 -99
  138. package/src/native/index.ts +23 -0
  139. package/src/native-properties.ts +188 -0
  140. package/src/native-property-driver.ts +123 -0
  141. package/src/native-property.ts +353 -0
  142. package/src/particles.ts +18 -17
  143. package/src/playback-group.ts +17 -1
  144. package/src/playback.ts +41 -0
  145. package/src/scene-appearance.ts +92 -0
  146. package/src/sequence.ts +209 -20
  147. package/src/timed-animation.ts +192 -0
  148. package/src/types.ts +1 -1
  149. package/src/value-observers.ts +133 -0
  150. package/src/value-track.ts +71 -0
  151. package/src/vue/index.ts +29 -5
@@ -20,14 +20,7 @@ import {
20
20
  validateDecorationTarget,
21
21
  } from './decoration.js'
22
22
  import { getDriver } from './driver/index.js'
23
- import {
24
- setArrival,
25
- setBase,
26
- stopBase,
27
- ViewAnimation,
28
- type Animation,
29
- type Plan,
30
- } from './engine.js'
23
+ import { setBase, stopBase, ViewAnimation, type Animation, type Plan } from './engine.js'
31
24
  import { linear } from './easing.js'
32
25
  import { isProperty, type Property } from './properties.js'
33
26
  import { createTrack, instantTrack, type Track } from './track.js'
@@ -39,19 +32,32 @@ import {
39
32
  type Transition,
40
33
  } from './transition.js'
41
34
  import type { KeyframeProperty, Target } from './types.js'
35
+ import { playbackSource, type PlaybackAnimation, type PlaybackClock } from './playback.js'
36
+ import { TimedAnimation } from './timed-animation.js'
37
+ import { valueTrack } from './value-track.js'
38
+ import {
39
+ ValueObservers,
40
+ type SubscribeOptions,
41
+ type ValueListener,
42
+ } from './value-observers.js'
42
43
 
43
44
  export interface MapOptions {
44
45
  /** Hold the first and last outputs outside the input range. Defaults to `true`. */
45
46
  clamp?: boolean
46
47
  }
47
48
 
49
+ /** Named motion value layers add scalar offsets before derived mappings. */
50
+ export type ValueAnimationOptions = Transition & { layer?: string }
51
+
48
52
  declare const motionValueBrand: unique symbol
49
53
 
50
54
  /** A number derived from a motion value, ready to bind to a view. */
51
55
  export interface DerivedValue {
52
56
  /** The value now, while it animates too. */
53
57
  get(): number
54
- /** Maps through input and output ranges, like React Native's `interpolate`. */
58
+ /** Observe changes in JavaScript on the native frame clock. Unsubscribe when finished. */
59
+ subscribe(listener: ValueListener<number>, options?: SubscribeOptions): () => void
60
+ /** Maps through ascending or descending input ranges, like React Native's `interpolate`. */
55
61
  to(
56
62
  input: readonly number[],
57
63
  output: readonly number[],
@@ -63,6 +69,8 @@ export interface DerivedValue {
63
69
  /** A color derived from a motion value, for `backgroundColor`, `borderColor` or `color`. */
64
70
  export interface DerivedColor {
65
71
  get(): Color
72
+ /** Observe mapped color changes in JavaScript on the native frame clock. */
73
+ subscribe(listener: ValueListener<Color>, options?: SubscribeOptions): () => void
66
74
  }
67
75
 
68
76
  /** A number that animates natively and drives every view property bound to it. */
@@ -88,12 +96,13 @@ interface Run {
88
96
  readonly track: Track
89
97
  readonly base: number
90
98
  readonly span: number
91
- readonly animation: Animation
99
+ readonly animation: TimedAnimation
92
100
  readonly startedAt: number
93
- readonly settle: (finished: boolean) => void
94
- readonly cancel: () => void
101
+ readonly clock?: Pick<PlaybackClock, 'time'>
95
102
  }
96
103
 
104
+ type Presentation = Pick<Run, 'track' | 'base' | 'span' | 'startedAt' | 'clock'>
105
+
97
106
  const bound = new WeakMap<View, Map<Property, Binding>>()
98
107
 
99
108
  class Derived {
@@ -112,6 +121,10 @@ class Derived {
112
121
  return this.color ? new Color(value) : value
113
122
  }
114
123
 
124
+ subscribe(listener: ValueListener<number | Color>, options?: SubscribeOptions) {
125
+ return this.root.observers.subscribe(this.read, this.color, listener, options)
126
+ }
127
+
115
128
  to(
116
129
  input: readonly number[],
117
130
  output: readonly (number | ColorValue)[],
@@ -126,46 +139,71 @@ class Derived {
126
139
 
127
140
  class Root extends Derived {
128
141
  readonly bindings = new Set<Binding>()
129
- private current: number
130
- private run: Run | undefined
142
+ readonly listeners = new Set<() => void>()
143
+ readonly observers = new ValueObservers((time) => this.raw(time))
144
+ private readonly rests = new Map<string, number>()
145
+ private readonly runs = new Map<string, Run>()
146
+ private presentation: Presentation | undefined
147
+ private cancelPresentation: (() => void) | undefined
131
148
 
132
149
  constructor(initial: number) {
133
150
  super(undefined, (value) => value, false)
134
151
  if (!Number.isFinite(initial))
135
152
  throw new RangeError(`A motion value starts from a finite number, got ${initial}.`)
136
- this.current = initial
153
+ this.rests.set('', initial)
154
+ }
155
+
156
+ raw(time = getDriver().now()): number {
157
+ let result = 0
158
+ for (const key of new Set([...this.rests.keys(), ...this.runs.keys()]))
159
+ result += this.part(key, time)
160
+ return result
161
+ }
162
+
163
+ /** Drag input owns only the base; bounds and snap points stay in base units. */
164
+ base(): number {
165
+ return this.part('')
166
+ }
167
+
168
+ private part(key: string, time = getDriver().now()): number {
169
+ const run = this.runs.get(key)
170
+ return run
171
+ ? run.base + run.span * run.track.valueAt(this.runTime(run, time))
172
+ : (this.rests.get(key) ?? 0)
137
173
  }
138
174
 
139
- raw(): number {
140
- const run = this.run
141
- if (!run) return this.current
142
- return run.base + run.span * run.track.valueAt(getDriver().now() - run.startedAt)
175
+ private runTime(run: Presentation, time = getDriver().now()) {
176
+ return run.clock?.time() ?? time - run.startedAt
143
177
  }
144
178
 
145
- private velocity(): number {
146
- const run = this.run
147
- if (!run) return 0
148
- return run.span * run.track.velocityAt(getDriver().now() - run.startedAt)
179
+ private velocity(key: string): number {
180
+ const run = this.runs.get(key)
181
+ if (!run || run.animation.paused) return 0
182
+ return run.span * run.track.velocityAt(this.runTime(run))
149
183
  }
150
184
 
151
185
  animate(
152
186
  target: number | readonly (number | null)[],
153
- transition: Transition | SpringName = {},
154
- ): Animation {
155
- const from = this.raw()
187
+ transition: ValueAnimationOptions | SpringName = {},
188
+ ): PlaybackAnimation {
189
+ const options: ValueAnimationOptions =
190
+ typeof transition === 'string' ? springOf(transition) : transition
191
+ const key = options.layer === undefined ? '' : layerName(options.layer)
192
+ const from = this.part(key)
156
193
  const frames = (typeof target === 'number' ? [null, target] : [...target]).map(
157
194
  (frame) => (frame === null ? from : frame),
158
195
  )
159
196
  if (frames.length < 2) frames.unshift(from)
160
197
  if (frames.some((frame) => typeof frame !== 'number' || !Number.isFinite(frame)))
161
198
  throw new RangeError(`Invalid motion value keyframes: ${JSON.stringify(target)}.`)
162
- const options = typeof transition === 'string' ? springOf(transition) : transition
163
199
  const resolved = resolveTransition(options, 'x', frames.length)
164
200
  const base = Math.min(...frames)
165
201
  const span = Math.max(...frames) - base
166
- const velocity = this.velocity()
167
- this.halt()
168
- if (span === 0) return this.jump(frames.at(-1)!)
202
+ const velocity = this.velocity(key)
203
+ if (span === 0) {
204
+ this.halt(key)
205
+ return this.jump(frames.at(-1)!, key)
206
+ }
169
207
 
170
208
  // Sampled in the value's own range so springs settle relative to the distance.
171
209
  const track = createTrack(
@@ -174,99 +212,158 @@ class Root extends Derived {
174
212
  resolved,
175
213
  velocity / span,
176
214
  )
177
- return this.start(track, base, span)
215
+ this.halt(key)
216
+ return track.end === 0
217
+ ? this.jump(base + span * track.to, key)
218
+ : this.start(track, base, span, key)
178
219
  }
179
220
 
180
221
  /** Plays a track sampled in the value's own units, such as a drag release. */
181
- play(track: Track): Animation {
182
- this.halt()
183
- return track.end === 0 ? this.jump(track.to) : this.start(track, 0, 1)
222
+ play(track: Track): PlaybackAnimation {
223
+ this.halt('')
224
+ return track.end === 0 ? this.jump(track.to, '') : this.start(track, 0, 1, '')
184
225
  }
185
226
 
186
- private jump(value: number): Animation {
187
- const { animation, settle } = this.handle()
188
- this.current = value
189
- for (const binding of this.bindings) binding.sync()
190
- settle(true)
191
- setArrival(animation, Promise.resolve())
227
+ private jump(value: number, key: string): PlaybackAnimation {
228
+ this.rests.set(key, value)
229
+ this.sync()
230
+ const animation = new TimedAnimation(
231
+ 0,
232
+ 0,
233
+ () => {},
234
+ () => {},
235
+ )
236
+ animation.start()
192
237
  return animation
193
238
  }
194
239
 
195
- private handle() {
196
- let settle!: (finished: boolean) => void
197
- const finished = new Promise<boolean>((resolve) => (settle = resolve))
198
- const animation: Animation = {
199
- finished,
200
- stop: () => {
201
- if (this.run?.animation === animation) this.stop()
202
- },
203
- then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
204
- }
205
- return { animation, settle }
206
- }
207
-
208
- private start(track: Track, base: number, span: number): Animation {
209
- const { animation, settle } = this.handle()
210
- let arrive: () => void = () => {}
211
- let run: Run | undefined
240
+ private start(
241
+ track: Track,
242
+ base: number,
243
+ span: number,
244
+ key: string,
245
+ ): PlaybackAnimation {
212
246
  const driver = getDriver()
213
- setArrival(animation, new Promise<void>((resolve) => (arrive = resolve)))
214
- const timers = [
215
- Number.isFinite(track.end)
216
- ? driver.after(track.end, () => {
217
- if (this.run !== run) return
218
- this.current = base + span * track.to
219
- this.run = undefined
220
- arrive()
221
- settle(true)
222
- })
223
- : undefined,
224
- Number.isFinite(track.arrival)
225
- ? driver.after(track.arrival, () => arrive())
226
- : undefined,
227
- ]
228
- this.run = run = {
247
+ const animation = new TimedAnimation(
248
+ track.end,
249
+ track.arrival,
250
+ () => this.sync(),
251
+ (completed) => {
252
+ if (this.runs.get(key)?.animation !== animation) return
253
+ const held = base + span * track.valueAt(animation.time)
254
+ this.runs.delete(key)
255
+ if (!completed && key !== '') this.rests.delete(key)
256
+ else this.rests.set(key, completed ? base + span * track.to : held)
257
+ this.sync()
258
+ },
259
+ )
260
+ const run: Run = {
229
261
  track,
230
262
  base,
231
263
  span,
232
264
  animation,
233
265
  startedAt: driver.now(),
234
- settle: (completed) => {
235
- arrive()
236
- settle(completed)
237
- },
238
- cancel: () => timers.forEach((cancel) => cancel?.()),
266
+ clock: { time: () => animation.time },
239
267
  }
240
- for (const binding of this.bindings) binding.sync()
268
+ this.runs.set(key, run)
269
+ this.sync()
270
+ animation.start()
241
271
  return animation
242
272
  }
243
273
 
244
274
  set(value: number) {
245
275
  if (!Number.isFinite(value))
246
276
  throw new RangeError(`A motion value takes a finite number, got ${value}.`)
247
- this.halt()
248
- this.current = value
249
- for (const binding of this.bindings) binding.sync()
277
+ for (const key of [...this.runs.keys()]) this.halt(key)
278
+ this.rests.clear()
279
+ this.rests.set('', value)
280
+ this.sync()
281
+ }
282
+
283
+ setBase(value: number) {
284
+ if (!Number.isFinite(value))
285
+ throw new RangeError('A motion value takes a finite number.')
286
+ this.halt('')
287
+ this.rests.set('', value)
288
+ this.sync()
289
+ }
290
+
291
+ stopBase() {
292
+ this.stopChannel('')
250
293
  }
251
294
 
252
- stop() {
253
- if (!this.run) return
295
+ stop(layer?: string) {
296
+ if (layer !== undefined) return this.stopChannel(layerName(layer))
297
+ if (!this.runs.size && this.rests.size === 1) return
254
298
  const value = this.raw()
255
- this.halt()
256
- this.current = value
299
+ for (const key of [...this.runs.keys()]) this.halt(key)
300
+ this.rests.clear()
301
+ this.rests.set('', value)
302
+ this.sync()
303
+ }
304
+
305
+ private stopChannel(key: string) {
306
+ if (!this.runs.has(key) && (key === '' || !this.rests.has(key))) return
307
+ const held = this.part(key)
308
+ this.halt(key)
309
+ if (key === '') this.rests.set(key, held)
310
+ else this.rests.delete(key)
311
+ this.sync()
312
+ }
313
+
314
+ private sync() {
315
+ this.cancelPresentation?.()
316
+ this.cancelPresentation = undefined
317
+ const now = getDriver().now()
318
+ let rest = 0
319
+ for (const [key, value] of this.rests) if (!this.runs.has(key)) rest += value
320
+ const runs = [...this.runs.values()].filter((run) => !run.animation.paused)
321
+ for (const [key, run] of this.runs)
322
+ if (run.animation.paused) rest += this.part(key, now)
323
+ if (!runs.length) this.presentation = undefined
324
+ else if (runs.length === 1) {
325
+ const run = runs[0]!
326
+ this.presentation = { ...run, base: run.base + rest }
327
+ } else {
328
+ const { track, windowed } = valueTrack(
329
+ runs.map((run) => ({
330
+ track: run.track,
331
+ base: run.base,
332
+ span: run.span,
333
+ elapsed: this.runTime(run, now),
334
+ })),
335
+ rest,
336
+ )
337
+ const driver = getDriver()
338
+ const clock = driver.clock?.(track.end)
339
+ this.presentation = { track, base: 0, span: 1, startedAt: now, clock }
340
+ const cancelWindow = windowed
341
+ ? driver.after(track.end, () => this.sync())
342
+ : undefined
343
+ this.cancelPresentation = () => {
344
+ cancelWindow?.()
345
+ clock?.cancel()
346
+ }
347
+ }
348
+ this.changed()
349
+ }
350
+
351
+ protected changed() {
257
352
  for (const binding of this.bindings) binding.sync()
353
+ for (const listener of [...this.listeners]) listener()
354
+ this.observers.changed(this.running)
258
355
  }
259
356
 
260
357
  /** The plan that plays this value's run, mapped, on one bound property. */
261
358
  plan(binding: Binding): Plan {
262
- const run = this.run
359
+ const run = this.presentation
263
360
  const { property, read } = binding
264
361
  const write = (value: number) =>
265
362
  isDecorationProperty(property)
266
363
  ? clampDecoration(property, read(value))
267
364
  : read(value)
268
365
  if (!run) {
269
- const value = write(this.current)
366
+ const value = write(this.raw())
270
367
  return {
271
368
  property,
272
369
  keyframes: [],
@@ -312,23 +409,38 @@ class Root extends Derived {
312
409
  return { property, keyframes: [], transition, track: mapped }
313
410
  }
314
411
 
412
+ /** Capture the current native presentation clock, even if a later command replaces it. */
413
+ captureTime(): () => number {
414
+ const run = this.presentation
415
+ return run ? () => this.runTime(run) : () => 0
416
+ }
417
+
315
418
  elapsed(): number {
316
- return this.run ? getDriver().now() - this.run.startedAt : 0
419
+ return this.presentation ? this.runTime(this.presentation) : 0
420
+ }
421
+
422
+ get paused(): boolean {
423
+ return this.runs.size > 0 && !this.running
317
424
  }
318
425
 
319
426
  get running(): boolean {
320
- return this.run !== undefined
427
+ return [...this.runs.values()].some((run) => !run.animation.paused)
321
428
  }
322
429
 
323
- private halt() {
324
- const run = this.run
430
+ private halt(key: string) {
431
+ const run = this.runs.get(key)
325
432
  if (!run) return
326
- this.run = undefined
327
- run.cancel()
328
- run.settle(false)
433
+ this.runs.delete(key)
434
+ run.animation.stop()
329
435
  }
330
436
  }
331
437
 
438
+ function layerName(name: string): string {
439
+ if (typeof name !== 'string' || !name.length)
440
+ throw new TypeError('A layer is named by a nonempty string.')
441
+ return name
442
+ }
443
+
332
444
  const still: ResolvedTransition = {
333
445
  curve: { type: 'tween', duration: 0, ease: [linear], times: undefined },
334
446
  delay: 0,
@@ -424,9 +536,13 @@ function attach(view: View, property: Property, source: Derived) {
424
536
  getDriver().sync?.(view)
425
537
  return
426
538
  }
427
- const elapsed = root.elapsed()
428
539
  const animation = new ViewAnimation(view, [root.plan(binding)])
429
- if (elapsed > 1 && animation.duration > 0) animation.seekTime(elapsed)
540
+ // Native keyframe setup can take time, especially for a composite track on Android.
541
+ // Catch up after construction so each binding joins the source's current phase.
542
+ const elapsed = root.elapsed()
543
+ if (root.paused) animation.pause()
544
+ if ((elapsed > 1 || root.paused) && animation.duration > 0)
545
+ animation.seekTime(elapsed)
430
546
  binding.animation = animation
431
547
  },
432
548
  }
@@ -444,6 +560,155 @@ function attach(view: View, property: Property, source: Derived) {
444
560
  }
445
561
  }
446
562
 
563
+ /** A read-only value on an existing finite animation's clock. No second animation or timer. */
564
+ export function playbackProgress(animation: Animation): DerivedValue {
565
+ const source = playbackSource(animation)
566
+ if (!Number.isFinite(source.read().duration))
567
+ throw new RangeError('Progress requires a finite animation.')
568
+ const root = new PlaybackValue(source)
569
+ return new Derived(root, (value) => value, false) as unknown as DerivedValue
570
+ }
571
+
572
+ class PlaybackValue extends Root {
573
+ constructor(private readonly playback: ReturnType<typeof playbackSource>) {
574
+ super(0)
575
+ const source = playback
576
+ const off = source.subscribe(() => {
577
+ this.changed()
578
+ if (source.read().settled) off()
579
+ })
580
+ if (source.read().settled) off()
581
+ this.observers.changed(this.running)
582
+ }
583
+
584
+ override raw() {
585
+ const state = this.playback.read()
586
+ return (
587
+ state.position ??
588
+ (state.completed
589
+ ? 1
590
+ : state.duration > 0
591
+ ? Math.max(0, Math.min(1, state.time / state.duration))
592
+ : 0)
593
+ )
594
+ }
595
+
596
+ override get running() {
597
+ const state = this.playback.read()
598
+ return state.ready && !state.settled && !state.paused && state.duration > 0
599
+ }
600
+
601
+ override get paused() {
602
+ return this.playback.read().paused
603
+ }
604
+ override captureTime() {
605
+ return () => this.playback.read().time
606
+ }
607
+ override elapsed() {
608
+ return this.playback.read().time
609
+ }
610
+
611
+ override plan(binding: Binding): Plan {
612
+ const state = this.playback.read()
613
+ if (!state.ready || state.settled || state.duration === 0)
614
+ return {
615
+ property: binding.property,
616
+ keyframes: [],
617
+ transition: still,
618
+ track: () => instantTrack(binding.property, binding.read(this.raw())),
619
+ }
620
+ // Bypass timeScale: the source duration is already in platform playback milliseconds.
621
+ const duration = state.duration
622
+ const at = (ms: number) => binding.read(Math.max(0, Math.min(1, ms / duration)))
623
+ const track: Track = {
624
+ property: binding.property,
625
+ delay: 0,
626
+ duration,
627
+ repeat: 0,
628
+ reverse: false,
629
+ times: [0, 1],
630
+ values: [at(0), at(duration)],
631
+ to: at(duration),
632
+ end: duration,
633
+ arrival: duration,
634
+ valueAt: at,
635
+ velocityAt: (ms) => (at(ms + 0.5) - at(ms - 0.5)) * 1000,
636
+ }
637
+ // Piecewise/color derivations need intermediate samples, not only the two endpoints.
638
+ const steps = Math.max(1, Math.min(6000, Math.ceil((duration * 60) / 1000)))
639
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
640
+ return {
641
+ property: binding.property,
642
+ keyframes: [],
643
+ transition: still,
644
+ track: () =>
645
+ reducedMotion() &&
646
+ binding.property !== 'opacity' &&
647
+ !isColorProperty(binding.property)
648
+ ? instantTrack(binding.property, at(duration))
649
+ : { ...track, times, values: times.map((time) => at(time * duration)) },
650
+ }
651
+ }
652
+ }
653
+
654
+ /** Internal compound-binding bridge. One raw snapshot per root keeps sibling mappings coherent. */
655
+ export function valueSource(source: DerivedValue | DerivedColor, color: boolean) {
656
+ if (!(source instanceof Derived) || source.color !== color)
657
+ throw new TypeError(
658
+ `Gradient component needs a ${color ? 'color' : 'numeric'} Jolt value.`,
659
+ )
660
+ const { root, read } = source
661
+ return {
662
+ identity: root,
663
+ read,
664
+ snapshot() {
665
+ return {
666
+ track: root.plan({ property: 'opacity', read: (value) => value } as Binding)
667
+ .track!(),
668
+ elapsed: root.elapsed(),
669
+ time: root.captureTime(),
670
+ running: root.running,
671
+ value: root.raw(),
672
+ }
673
+ },
674
+ subscribe(listener: () => void) {
675
+ root.listeners.add(listener)
676
+ return () => {
677
+ root.listeners.delete(listener)
678
+ }
679
+ },
680
+ }
681
+ }
682
+
683
+ /** Treat values as opaque identities in declarative option comparisons. */
684
+ export function isDerived(value: unknown): value is DerivedValue | DerivedColor {
685
+ return value instanceof Derived
686
+ }
687
+
688
+ /** Internal native-adapter bridge. Mappings are sampled at boundaries, never per frame. */
689
+ export function valuePlayback(source: DerivedValue) {
690
+ if (!(source instanceof Derived) || source.color)
691
+ throw new TypeError('A native binding needs a numeric Jolt value.')
692
+ const { root, read } = source
693
+ return {
694
+ snapshot() {
695
+ return {
696
+ track: root.plan({ property: 'opacity', read } as Binding).track!(),
697
+ elapsed: root.elapsed(),
698
+ paused: root.paused,
699
+ running: root.running,
700
+ value: read(root.raw()),
701
+ }
702
+ },
703
+ subscribe(listener: () => void) {
704
+ root.listeners.add(listener)
705
+ return () => {
706
+ root.listeners.delete(listener)
707
+ }
708
+ },
709
+ }
710
+ }
711
+
447
712
  function release(binding: Binding) {
448
713
  binding.root.bindings.delete(binding)
449
714
  binding.view.off('loaded', binding.sync)
@@ -472,12 +737,22 @@ function mapping(
472
737
  throw new RangeError(
473
738
  `to() has ${input.length} inputs but ${output.length} outputs; give one output per input.`,
474
739
  )
740
+ if (input.some((value) => !Number.isFinite(value)))
741
+ throw new RangeError('to() inputs must all be finite numbers.')
742
+ const descending = input[input.length - 1]! < input[0]!
475
743
  if (
476
744
  input.some(
477
- (value, i) => !Number.isFinite(value) || value < (input[i - 1] ?? -Infinity),
745
+ (value, i) => i > 0 && (descending ? value > input[i - 1]! : value < input[i - 1]!),
478
746
  )
479
747
  )
480
- throw new RangeError('to() inputs must be finite and never decrease.')
748
+ throw new RangeError(
749
+ 'to() inputs must be monotonic (ascending or descending); ranges cannot change direction.',
750
+ )
751
+ // Normalize pairs together, without changing caller-owned arrays or duplicate-stop rules.
752
+ if (descending) {
753
+ input = [...input].reverse()
754
+ output = [...output].reverse()
755
+ }
481
756
  const color = typeof output[0] !== 'number'
482
757
  const numbers = color ? output.map((value) => parseColor(value)) : (output as number[])
483
758
  if (
@@ -0,0 +1,23 @@
1
+ export { registerNativeProperty, checkNativeProperty } from '../native-property.js'
2
+ export type { NativeProperty, NativePropertyDefinition } from '../native-property.js'
3
+ export type { NativeSink } from '../native-property-driver.js'
4
+ export {
5
+ mapKitCoordinate,
6
+ mapKitCamera,
7
+ mapLibreCoordinate,
8
+ mapLibreCamera,
9
+ sceneKitPose,
10
+ } from '../native-properties.js'
11
+ export type {
12
+ Coordinate,
13
+ MapKitCamera,
14
+ MapLibreCamera,
15
+ ScenePose,
16
+ } from '../native-properties.js'
17
+ export {
18
+ sceneKitLightIntensity,
19
+ sceneKitLightColor,
20
+ sceneKitMaterialTransparency,
21
+ sceneKitMaterialDiffuse,
22
+ sceneKitMaterialEmission,
23
+ } from '../scene-appearance.js'