@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
package/src/particles.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Application, Color, isIOS, type View } from '@nativescript/core'
1
+ import { Application, Color, isAndroid, isIOS, type View } from '@nativescript/core'
2
2
  import { toView } from './animate.js'
3
3
  import type { Target } from './types.js'
4
- import { reducedMotion } from './config.js'
4
+ import { onReducedMotion, reducedMotion } from './config.js'
5
+ import { androidParticles } from './driver/android-particles.js'
5
6
 
6
7
  export interface ConfettiOptions {
7
8
  /** Approximate number of particles in the burst. Defaults to 80. */
@@ -38,7 +39,10 @@ export interface ParticleOptions {
38
39
  duration?: number
39
40
  }
40
41
 
41
- type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
42
+ export type ParticleSettings = Required<Omit<ParticleOptions, 'duration'>> & {
43
+ duration?: number
44
+ }
45
+ type Settings = ParticleSettings
42
46
  const bursts = new WeakMap<View, () => void>()
43
47
  const emitters = new WeakMap<View, () => void>()
44
48
  const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
@@ -46,7 +50,7 @@ const textures = new Map<Settings['shape'], UIImage>()
46
50
  const radians = (degrees: number) => (degrees * Math.PI) / 180
47
51
  const BURST_DURATION = 200
48
52
 
49
- /** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
53
+ /** Bursts from the target's center into its window. Returns immediate cleanup. */
50
54
  export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
51
55
  const count = number('count', options.count ?? 80, 0, 10000)
52
56
  if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
@@ -70,7 +74,7 @@ export function confetti(target: Target, options: ConfettiOptions = {}): () => v
70
74
  return start(target, settings, true)
71
75
  }
72
76
 
73
- /** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
77
+ /** Adds a native particle emitter to the target. Returns immediate cleanup. */
74
78
  export function particles(target: Target, options: ParticleOptions = {}): () => void {
75
79
  return start(target, resolve(options), false)
76
80
  }
@@ -79,7 +83,9 @@ function start(target: Target, settings: Settings, overlay: boolean): () => void
79
83
  const view = toView(target)
80
84
  const running = overlay ? bursts : emitters
81
85
  running.get(view)?.()
82
- if (!isIOS || settings.rate === 0 || reducedMotion()) return () => {}
86
+ if (settings.rate === 0 || reducedMotion()) return () => {}
87
+ if (isAndroid) return androidParticles(view, settings, overlay, running)
88
+ if (!isIOS) return () => {}
83
89
  return new Emitter(view, settings, overlay, running).stop
84
90
  }
85
91
 
@@ -141,7 +147,7 @@ class Emitter {
141
147
  private native: UIView | undefined
142
148
  private window: UIWindow | undefined
143
149
  private timer: ReturnType<typeof setTimeout> | undefined
144
- private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
150
+ private unwatch: (() => void) | undefined
145
151
 
146
152
  constructor(
147
153
  private readonly view: View,
@@ -153,6 +159,8 @@ class Emitter {
153
159
  view.on('loaded', this.schedule)
154
160
  view.on('layoutChanged', this.schedule)
155
161
  view.on('unloaded', this.stop)
162
+ Application.on(Application.suspendEvent, this.stop)
163
+ this.unwatch = onReducedMotion(this.motionChanged)
156
164
  this.schedule()
157
165
  }
158
166
 
@@ -162,17 +170,14 @@ class Emitter {
162
170
  this.view.off('loaded', this.schedule)
163
171
  this.view.off('layoutChanged', this.schedule)
164
172
  this.view.off('unloaded', this.stop)
173
+ Application.off(Application.suspendEvent, this.stop)
165
174
  if (this.running.get(this.view) === this.stop) this.running.delete(this.view)
166
175
  if (this.timer !== undefined) clearTimeout(this.timer)
167
- if (this.observer)
168
- Application.ios.removeNotificationObserver(
169
- this.observer,
170
- UIAccessibilityReduceMotionStatusDidChangeNotification,
171
- )
176
+ this.unwatch?.()
172
177
  this.layer?.removeAllAnimations()
173
178
  this.layer?.removeFromSuperlayer()
174
179
  this.layer = this.native = this.window = undefined
175
- this.timer = this.observer = undefined
180
+ this.timer = this.unwatch = undefined
176
181
  }
177
182
 
178
183
  private schedule = () => {
@@ -232,10 +237,6 @@ class Emitter {
232
237
  ),
233
238
  window,
234
239
  )
235
- this.observer = Application.ios.addNotificationObserver(
236
- UIAccessibilityReduceMotionStatusDidChangeNotification,
237
- this.motionChanged,
238
- )
239
240
  if (this.settings.duration !== undefined) {
240
241
  // The render server stops emission even if JS is blocked. The timer
241
242
  // only removes the empty layer after the last particles have expired.
@@ -6,7 +6,7 @@ import {
6
6
  type ViewAnimation,
7
7
  } from './engine.js'
8
8
  import { getDriver } from './driver/index.js'
9
- import { createClock, type PlaybackClock } from './playback.js'
9
+ import { createClock, registerPlayback, type PlaybackClock } from './playback.js'
10
10
  import { validateProgress, type PlaybackAnimation } from './playback.js'
11
11
 
12
12
  /** Staggered views share a finite time range. Shorter children remain seekable
@@ -17,6 +17,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
17
17
  let finalProgress: number | undefined
18
18
  let clock: PlaybackClock | undefined
19
19
  let pendingProgress: number | undefined
20
+ let changed = () => {}
20
21
  const duration = () => Math.max(0, ...children.map((child) => child.duration))
21
22
  const ensureClock = () => {
22
23
  if (clock || settled || children.some((child) => !child.ready)) return
@@ -44,6 +45,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
44
45
  clock?.seek(total * value)
45
46
  for (const child of children) child.seekTime(total * value)
46
47
  finishIfIdle()
48
+ changed()
47
49
  }
48
50
  for (const child of children) {
49
51
  child.onIdle(finishIfIdle)
@@ -52,6 +54,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
52
54
  if (pendingProgress !== undefined && children.every((child) => child.ready))
53
55
  seek(pendingProgress)
54
56
  finishIfIdle()
57
+ changed()
55
58
  })
56
59
  }
57
60
  const finished = Promise.all(children.map((child) => child.finished)).then(
@@ -63,6 +66,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
63
66
  : 0
64
67
  settled = true
65
68
  clock?.cancel()
69
+ changed()
66
70
  return results.every(Boolean)
67
71
  },
68
72
  )
@@ -91,6 +95,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
91
95
  paused = true
92
96
  clock?.pause()
93
97
  children.forEach((child) => child.pause())
98
+ changed()
94
99
  }
95
100
  },
96
101
  play() {
@@ -99,6 +104,7 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
99
104
  clock?.play()
100
105
  children.forEach((child) => child.play())
101
106
  finishIfIdle()
107
+ changed()
102
108
  }
103
109
  },
104
110
  stop() {
@@ -106,9 +112,19 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
106
112
  settled = true
107
113
  clock?.cancel()
108
114
  children.forEach((child) => child.stop())
115
+ changed()
109
116
  },
110
117
  then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
111
118
  }
119
+ changed = registerPlayback(animation, () => ({
120
+ position: pendingProgress,
121
+ time: finalProgress === undefined ? (clock?.time() ?? 0) : finalProgress * duration(),
122
+ duration: duration(),
123
+ paused,
124
+ ready: children.every((child) => child.ready),
125
+ settled,
126
+ completed: finalProgress === 1,
127
+ }))
112
128
  setArrival(animation, Promise.all(children.map(arrivalOf)))
113
129
  const listeners = new Set<() => void>()
114
130
  const timings = children.map((child) => arrivalTimingOf(child)!)
package/src/playback.ts CHANGED
@@ -23,6 +23,47 @@ export interface PlaybackClock {
23
23
  cancel(): void
24
24
  }
25
25
 
26
+ /** Boundary notifications, never frame callbacks. Consumers sample this same clock. */
27
+ export interface PlaybackState {
28
+ readonly position?: number
29
+ readonly time: number
30
+ readonly duration: number
31
+ readonly paused: boolean
32
+ readonly ready: boolean
33
+ readonly settled: boolean
34
+ readonly completed: boolean
35
+ }
36
+
37
+ const sources = new WeakMap<
38
+ Animation,
39
+ {
40
+ read(): PlaybackState
41
+ listeners: Set<() => void>
42
+ }
43
+ >()
44
+
45
+ export function registerPlayback(animation: Animation, read: () => PlaybackState) {
46
+ const source = { read, listeners: new Set<() => void>() }
47
+ sources.set(animation, source)
48
+ return () => {
49
+ for (const listener of [...source.listeners]) listener()
50
+ }
51
+ }
52
+
53
+ export function playbackSource(animation: Animation) {
54
+ const source = sources.get(animation)
55
+ if (!source) throw new TypeError('This animation does not expose a playback clock.')
56
+ return {
57
+ read: source.read,
58
+ subscribe(listener: () => void) {
59
+ source.listeners.add(listener)
60
+ return () => {
61
+ source.listeners.delete(listener)
62
+ }
63
+ },
64
+ }
65
+ }
66
+
26
67
  /** Read only on user commands; there is no frame loop in JavaScript. */
27
68
  export function createClock(now: () => number, initial = 0): PlaybackClock {
28
69
  let offset = initial
@@ -0,0 +1,92 @@
1
+ /// <reference types="@nativescript/types-ios/lib/ios/objc-x86_64/objc!SceneKit" preserve="true" />
2
+ import { Color } from '@nativescript/core'
3
+ import { colorMixer, parseColor, type ColorValue } from './color.js'
4
+ import { registerNativeProperty, type NativeProperty } from './native-property.js'
5
+
6
+ const clamp = (n: number) => Math.max(0, Math.min(1, n))
7
+ function scalar(name: string, maximum: number, create: (target: object) => object) {
8
+ return registerNativeProperty<number>({
9
+ name: `jolt.scenekit/${name}`,
10
+ units: [
11
+ name === 'light-intensity'
12
+ ? 'lumens (PBR); 1000 = unit multiplier otherwise'
13
+ : 'opacity fraction',
14
+ ],
15
+ platforms: ['ios'],
16
+ encode: (value) => [value],
17
+ decode: (values) => values[0]!,
18
+ validate(value) {
19
+ if (!Number.isFinite(value) || value < 0 || value > maximum)
20
+ throw new RangeError(`${name} needs a finite value in 0..${maximum}.`)
21
+ },
22
+ interpolate: (a, b, t) => Math.max(0, Math.min(maximum, a + (b - a) * t)),
23
+ create,
24
+ })
25
+ }
26
+ function color(name: string, create: (target: object) => object) {
27
+ return registerNativeProperty<ColorValue>({
28
+ name: `jolt.scenekit/${name}`,
29
+ // Premultiply the sampled sRGB channels, so native linear interpolation also handles zero alpha.
30
+ units: [
31
+ 'premultiplied sRGB red',
32
+ 'premultiplied sRGB green',
33
+ 'premultiplied sRGB blue',
34
+ 'alpha',
35
+ ],
36
+ platforms: ['ios'],
37
+ encode(value) {
38
+ const argb = parseColor(value),
39
+ alpha = (argb >>> 24) / 255
40
+ return [
41
+ (((argb >>> 16) & 255) / 255) * alpha,
42
+ (((argb >>> 8) & 255) / 255) * alpha,
43
+ ((argb & 255) / 255) * alpha,
44
+ alpha,
45
+ ]
46
+ },
47
+ decode(values) {
48
+ const alpha = clamp(values[3]!)
49
+ const channel = (i: number) =>
50
+ Math.round(clamp(alpha ? values[i]! / alpha : 0) * 255)
51
+ return new Color(
52
+ ((Math.round(alpha * 255) << 24) |
53
+ (channel(0) << 16) |
54
+ (channel(1) << 8) |
55
+ channel(2)) >>>
56
+ 0,
57
+ )
58
+ },
59
+ interpolate: (a, b, t) => new Color(colorMixer(parseColor(a), parseColor(b))(t)),
60
+ create,
61
+ })
62
+ }
63
+
64
+ /** SCNLight intensity, finite and nonnegative. Non-spatial: retains timing under reduced motion. */
65
+ export const sceneKitLightIntensity: (light: SCNLight) => NativeProperty<number> = scalar(
66
+ 'light-intensity',
67
+ Infinity,
68
+ (target) => JoltReferenceProperties.sceneLightIntensity(target as NSObject),
69
+ )
70
+ /** Solid UIColor/CGColor light color. Returns a normalized sRGB Color from get(). */
71
+ export const sceneKitLightColor: (light: SCNLight) => NativeProperty<ColorValue> = color(
72
+ 'light-color',
73
+ (target) => JoltReferenceProperties.sceneLightColor(target as NSObject),
74
+ )
75
+ /** SCNMaterial transparency in 0..1; does not change transparencyMode/transparent contents. */
76
+ export const sceneKitMaterialTransparency: (
77
+ material: SCNMaterial,
78
+ ) => NativeProperty<number> = scalar('material-transparency', 1, (target) =>
79
+ JoltReferenceProperties.sceneMaterialTransparency(target as NSObject),
80
+ )
81
+ /** Solid color diffuse contents only; never converts a texture into a color. */
82
+ export const sceneKitMaterialDiffuse: (
83
+ material: SCNMaterial,
84
+ ) => NativeProperty<ColorValue> = color('material-diffuse', (target) =>
85
+ JoltReferenceProperties.sceneMaterialDiffuse(target as NSObject),
86
+ )
87
+ /** Solid color emission contents only; does not make the material illuminate nearby geometry. */
88
+ export const sceneKitMaterialEmission: (
89
+ material: SCNMaterial,
90
+ ) => NativeProperty<ColorValue> = color('material-emission', (target) =>
91
+ JoltReferenceProperties.sceneMaterialEmission(target as NSObject),
92
+ )
package/src/sequence.ts CHANGED
@@ -10,7 +10,12 @@ import {
10
10
  stopView,
11
11
  type Animation,
12
12
  } from './engine.js'
13
- import { createClock } from './playback.js'
13
+ import { createClock, registerPlayback, playbackSource } from './playback.js'
14
+ import {
15
+ isMotionValue,
16
+ type MotionValue,
17
+ type ValueAnimationOptions,
18
+ } from './motion-value.js'
14
19
  import type { PresetName } from './presets.js'
15
20
  import { writeValue, type Property } from './properties.js'
16
21
  import type { SpringName } from './transition.js'
@@ -28,6 +33,34 @@ export type Step =
28
33
  ]
29
34
  | (() => Animation)
30
35
 
36
+ /** A participant that can genuinely freeze and continue, rather than restart. */
37
+ export interface PlayableAnimation extends Animation {
38
+ pause(): void
39
+ play(): void
40
+ readonly paused: boolean
41
+ readonly duration?: number
42
+ }
43
+
44
+ export type TimelineStep =
45
+ | Exclude<Step, () => Animation>
46
+ | readonly [
47
+ MotionValue,
48
+ number | readonly (number | null)[],
49
+ ((ValueAnimationOptions & { at?: SequencePosition }) | SpringName)?,
50
+ ]
51
+ | { readonly at?: SequencePosition; readonly run: () => PlayableAnimation }
52
+ | (() => PlayableAnimation)
53
+
54
+ export interface TimelineAnimation extends PlayableAnimation {
55
+ /** Unknown until every step has activated and every participant reports a duration. */
56
+ readonly duration: number | undefined
57
+ }
58
+
59
+ /** Overlapping steps with a shared pause/resume control, including pending launches. */
60
+ export function timeline(steps: readonly TimelineStep[]): TimelineAnimation {
61
+ return compose(steps, true) as TimelineAnimation
62
+ }
63
+
31
64
  /**
32
65
  * By default a step starts once the previous one looks done. `at` can overlap
33
66
  * that arrival, share the previous start, or select an absolute timeline time.
@@ -44,26 +77,54 @@ export type Step =
44
77
  * ```
45
78
  */
46
79
  export function sequence(steps: readonly Step[]): Animation {
80
+ return compose(steps, false)
81
+ }
82
+
83
+ function compose(
84
+ steps: readonly TimelineStep[] | readonly Step[],
85
+ controlled: boolean,
86
+ ): Animation {
47
87
  const prepared = steps.map((step) => {
48
88
  if (typeof step === 'function') return { step, position: undefined }
49
- const [target, animation, options] = step
89
+ if (!Array.isArray(step)) {
90
+ const descriptor = step as { at?: SequencePosition; run: () => PlayableAnimation }
91
+ if (typeof descriptor.run !== 'function')
92
+ throw new TypeError('A timeline step needs a run() factory.')
93
+ return { step: descriptor.run, position: parsePosition(descriptor.at) }
94
+ }
95
+ const [target, animation, options] = step as Exclude<
96
+ TimelineStep,
97
+ Function | { run: () => PlayableAnimation }
98
+ >
50
99
  if (typeof options !== 'object' || options === null)
51
100
  return { step, position: undefined }
52
101
  const { at, ...transition } = options
53
102
  const position = parsePosition(at)
54
103
  return { step: [target, animation, transition] as Step, position }
55
104
  })
56
- applyStartingValues(prepared.map(({ step }) => step))
105
+ applyStartingValues(
106
+ prepared
107
+ .map(({ step }) => step as Step)
108
+ .filter((step) => typeof step === 'function' || !isMotionValue(step[0])),
109
+ )
57
110
 
58
111
  const driver = getDriver()
59
112
  const slowdown = 1 / timeScale()
60
113
  // Absolute positions share the platform clock, including Android duration scale.
61
- const clock = prepared.some(({ position }) => typeof position === 'number')
62
- ? (driver.clock?.(Infinity) ?? createClock(() => driver.now()))
63
- : undefined
114
+ const clock =
115
+ controlled || prepared.some(({ position }) => typeof position === 'number')
116
+ ? (driver.clock?.(Infinity) ?? createClock(() => driver.now()))
117
+ : undefined
64
118
  const started: Animation[] = []
65
119
  const cancellations = new Set<() => void>()
66
120
  let stopped = false
121
+ let paused = false
122
+ let settled = false
123
+ let completed = false
124
+ let changed = () => {}
125
+ const resumeChildren = new Set<PlayableAnimation>()
126
+ const waits = new Set<{ at: number; fire(): void; cancel(): void; schedule(): void }>()
127
+ const time = () => clock?.time() ?? 0
67
128
  let resolveScheduled!: () => void
68
129
  let rejectScheduled!: (reason: unknown) => void
69
130
  const scheduled = new Promise<void>((resolve, reject) => {
@@ -77,28 +138,55 @@ export function sequence(steps: readonly Step[]): Animation {
77
138
  position,
78
139
  onStart: new Set<() => void>(),
79
140
  animation: undefined as Animation | undefined,
141
+ start: undefined as number | undefined,
80
142
  }
81
143
  })
82
144
  const cancelSchedule = () => {
83
145
  for (const cancel of cancellations) cancel()
84
146
  cancellations.clear()
85
- clock?.cancel()
147
+ for (const wait of waits) wait.cancel()
148
+ waits.clear()
149
+ if (!controlled || stopped || settled) clock?.cancel()
86
150
  }
87
151
  const fail = (error: unknown) => {
88
152
  if (stopped) return
89
153
  stopped = true
154
+ settled = true
90
155
  cancelSchedule()
91
156
  states.forEach((state) => state.onStart.clear())
92
157
  started.forEach((child) => child.stop())
158
+ changed()
93
159
  rejectScheduled(error)
94
160
  }
95
161
  const launch = (index: number) => {
96
162
  const state = states[index]!
97
163
  if (stopped || state.animation) return
164
+ if (paused) {
165
+ wait(0, () => launch(index))
166
+ return
167
+ }
98
168
  try {
99
169
  const step = state.step
100
- const child = typeof step === 'function' ? step() : animate(...step)
170
+ const child =
171
+ typeof step === 'function'
172
+ ? step()
173
+ : (animate as (...args: any[]) => Animation)(...(step as any[]))
174
+ if (controlled && !isPlayable(child)) {
175
+ child?.stop?.()
176
+ throw new TypeError(
177
+ 'A timeline participant must expose pause(), play() and paused; use sequence() for non-playable effects.',
178
+ )
179
+ }
101
180
  state.animation = child
181
+ state.start = time()
182
+ if (controlled) {
183
+ try {
184
+ const playback = playbackSource(child).read()
185
+ if (playback.ready) state.start = Math.max(0, time() - playback.time)
186
+ } catch {
187
+ /* A custom playable participant can omit a clock. */
188
+ }
189
+ }
102
190
  started.push(child)
103
191
  // Observe setup failures immediately, even while other steps are still
104
192
  // scheduled. Nested sequences can reject before this one reaches arrival.
@@ -110,25 +198,51 @@ export function sequence(steps: readonly Step[]): Animation {
110
198
  cancelSchedule()
111
199
  resolveScheduled()
112
200
  }
201
+ changed()
113
202
  } catch (error) {
114
203
  fail(error)
115
204
  }
116
205
  }
117
206
  const wait = (ms: number, callback: () => void) => {
118
207
  if (stopped) return
119
- if (ms <= 0) {
120
- callback()
208
+ if (!controlled) {
209
+ if (ms <= 0) {
210
+ callback()
211
+ return
212
+ }
213
+ let cancel = () => {}
214
+ let fired = false
215
+ const off = driver.after(ms, () => {
216
+ fired = true
217
+ cancellations.delete(cancel)
218
+ if (!stopped) callback()
219
+ })
220
+ cancel = off
221
+ if (!fired) cancellations.add(cancel)
121
222
  return
122
223
  }
123
- let cancel = () => {}
124
- let fired = false
125
- const off = driver.after(ms, () => {
126
- fired = true
127
- cancellations.delete(cancel)
128
- if (!stopped) callback()
129
- })
130
- cancel = off
131
- if (!fired) cancellations.add(cancel)
224
+ let off = () => {}
225
+ const waiting = {
226
+ at: time() + Math.max(0, ms),
227
+ fire() {
228
+ off()
229
+ waits.delete(waiting)
230
+ if (!stopped) callback()
231
+ },
232
+ cancel() {
233
+ off()
234
+ off = () => {}
235
+ },
236
+ schedule() {
237
+ waiting.cancel()
238
+ if (paused || stopped) return
239
+ const remaining = waiting.at - time()
240
+ if (remaining <= 0) waiting.fire()
241
+ else off = driver.after(remaining, waiting.fire)
242
+ },
243
+ }
244
+ waits.add(waiting)
245
+ waiting.schedule()
132
246
  }
133
247
 
134
248
  for (let index = 0; index < states.length; index++) {
@@ -214,7 +328,11 @@ export function sequence(steps: readonly Step[]): Animation {
214
328
  const arrived = scheduled.then(() => Promise.all(started.map(arrivalOf)))
215
329
  const finished = arrived.then(async () => {
216
330
  const results = await Promise.all(started.map((animation) => animation.finished))
217
- return !stopped && results.length === steps.length && results.every(Boolean)
331
+ completed = !stopped && results.length === steps.length && results.every(Boolean)
332
+ settled = true
333
+ clock?.cancel()
334
+ changed()
335
+ return completed
218
336
  })
219
337
 
220
338
  const animation: Animation = {
@@ -222,13 +340,69 @@ export function sequence(steps: readonly Step[]): Animation {
222
340
  stop() {
223
341
  if (stopped) return
224
342
  stopped = true
343
+ settled = true
225
344
  cancelSchedule()
226
345
  states.forEach((state) => state.onStart.clear())
227
346
  resolveScheduled()
228
347
  started.forEach((animation) => animation.stop())
348
+ resumeChildren.clear()
349
+ changed()
229
350
  },
230
351
  then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
231
352
  }
353
+ if (controlled) {
354
+ const duration = () => {
355
+ if (pending) return undefined
356
+ let total = 0
357
+ for (const state of states) {
358
+ const child = state.animation as PlayableAnimation | undefined
359
+ if (!child || child.duration === undefined) return undefined
360
+ try {
361
+ if (!playbackSource(child).read().ready) return undefined
362
+ } catch {
363
+ /* custom playable */
364
+ }
365
+ total = Math.max(total, (state.start ?? 0) + child.duration)
366
+ }
367
+ return total
368
+ }
369
+ Object.defineProperties(animation, {
370
+ paused: { get: () => paused },
371
+ duration: { get: duration },
372
+ })
373
+ Object.assign(animation, {
374
+ pause() {
375
+ if (paused || settled) return
376
+ paused = true
377
+ clock?.pause()
378
+ for (const wait of waits) wait.cancel()
379
+ for (const child of started as PlayableAnimation[]) {
380
+ if (!child.paused) {
381
+ resumeChildren.add(child)
382
+ child.pause()
383
+ }
384
+ }
385
+ changed()
386
+ },
387
+ play() {
388
+ if (!paused || settled) return
389
+ paused = false
390
+ clock?.play()
391
+ for (const child of resumeChildren) child.play()
392
+ resumeChildren.clear()
393
+ for (const wait of [...waits]) wait.schedule()
394
+ changed()
395
+ },
396
+ })
397
+ changed = registerPlayback(animation, () => ({
398
+ time: time(),
399
+ duration: duration() ?? Infinity,
400
+ paused,
401
+ ready: duration() !== undefined,
402
+ settled,
403
+ completed,
404
+ }))
405
+ }
232
406
  setArrival(animation, arrived)
233
407
  return animation
234
408
  }
@@ -276,3 +450,18 @@ function applyStartingValues(steps: readonly Step[]) {
276
450
  }
277
451
  }
278
452
  }
453
+
454
+ function isPlayable(animation: Animation): animation is PlayableAnimation {
455
+ const handle = animation as PlayableAnimation | undefined
456
+ return (
457
+ !!handle &&
458
+ typeof handle.pause === 'function' &&
459
+ typeof handle.play === 'function' &&
460
+ typeof handle.paused === 'boolean' &&
461
+ typeof handle.stop === 'function' &&
462
+ typeof handle.finished?.then === 'function' &&
463
+ typeof handle.finished?.catch === 'function' &&
464
+ (handle.duration === undefined ||
465
+ (typeof handle.duration === 'number' && handle.duration >= 0))
466
+ )
467
+ }