@leavepulse/ui 0.48.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, ref, computed, watch, onMounted, onBeforeUnmount, openBlock, createElementBlock, mergeProps, unref, normalizeStyle, createCommentVNode, createElementVNode, normalizeClass } from "vue";
2
- import { prefersReducedMotion } from "../composables/easing.js";
2
+ import { prefersReducedMotion, easeTravel } from "../composables/easing.js";
3
3
  import { useMergedAttrs } from "../composables/useMergedClass.js";
4
4
  import { planLoop, isNativeLoop, seamReached, seamProgress } from "../components/videoBackdrop.js";
5
5
  const _hoisted_1 = ["src"];
@@ -19,7 +19,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
19
19
  dim: { default: 0 },
20
20
  fit: { default: "cover" },
21
21
  paused: { type: Boolean, default: false },
22
- playWithReducedMotion: { type: Boolean, default: false }
22
+ playWithReducedMotion: { type: Boolean, default: false },
23
+ easePlayback: { default: 600 }
23
24
  },
24
25
  emits: ["loaded", "error"],
25
26
  setup(__props, { emit: __emit }) {
@@ -32,6 +33,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
32
33
  const active = ref(0);
33
34
  const incoming = ref(null);
34
35
  const incomingOpacity = ref(0);
36
+ const running = ref(false);
37
+ const throttle = ref(1);
38
+ const SLOWEST_RATE = 1 / 16;
35
39
  const reducedMotion = ref(prefersReducedMotion());
36
40
  const pageHidden = ref(typeof document !== "undefined" && document.hidden);
37
41
  const plan = computed(
@@ -47,7 +51,36 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
47
51
  function setRate(video) {
48
52
  if (!video) return;
49
53
  video.defaultPlaybackRate = props.rate;
50
- video.playbackRate = props.rate;
54
+ video.playbackRate = Math.max(SLOWEST_RATE, props.rate * throttle.value);
55
+ }
56
+ function applyRate() {
57
+ setRate(first.value);
58
+ setRate(second.value);
59
+ }
60
+ let ramp = 0;
61
+ function rampTo(target, ms, done) {
62
+ if (ramp) cancelAnimationFrame(ramp);
63
+ ramp = 0;
64
+ const from = throttle.value;
65
+ const span = ms * Math.abs(target - from);
66
+ if (span <= 0) {
67
+ throttle.value = target;
68
+ applyRate();
69
+ done?.();
70
+ return;
71
+ }
72
+ const began = performance.now();
73
+ const step = (now) => {
74
+ const k = Math.min(1, (now - began) / span);
75
+ throttle.value = from + (target - from) * easeTravel(k);
76
+ applyRate();
77
+ if (k < 1) ramp = requestAnimationFrame(step);
78
+ else {
79
+ ramp = 0;
80
+ done?.();
81
+ }
82
+ };
83
+ ramp = requestAnimationFrame(step);
51
84
  }
52
85
  function onMetadata(event) {
53
86
  const video = event.target;
@@ -86,7 +119,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
86
119
  let frame = 0;
87
120
  function watchFrames() {
88
121
  frame = 0;
89
- if (!playing.value) return;
122
+ if (!running.value) return;
90
123
  const current = element(active.value);
91
124
  const loop = plan.value;
92
125
  if (current && !native.value) {
@@ -113,28 +146,43 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
113
146
  }
114
147
  frame = requestAnimationFrame(watchFrames);
115
148
  }
116
- function sync() {
149
+ function startElements() {
150
+ running.value = true;
151
+ applyRate();
152
+ void element(active.value)?.play().catch(() => {
153
+ });
154
+ if (incoming.value !== null) void element(incoming.value)?.play().catch(() => {
155
+ });
156
+ if (!frame) frame = requestAnimationFrame(watchFrames);
157
+ }
158
+ function stopElements() {
159
+ running.value = false;
160
+ first.value?.pause();
161
+ second.value?.pause();
162
+ if (frame) cancelAnimationFrame(frame);
163
+ frame = 0;
117
164
  const current = element(active.value);
118
- const other = element(active.value === 0 ? 1 : 0);
165
+ if (still.value && current && ready.value) current.currentTime = plan.value.from;
166
+ }
167
+ function sync() {
168
+ const easing = props.easePlayback > 0 && !prefersReducedMotion() ? props.easePlayback : 0;
119
169
  if (playing.value) {
120
- void current?.play().catch(() => {
121
- });
122
- if (incoming.value !== null) void other?.play().catch(() => {
123
- });
124
- if (!frame) frame = requestAnimationFrame(watchFrames);
170
+ if (!running.value) throttle.value = easing ? 0 : 1;
171
+ startElements();
172
+ rampTo(1, easing);
125
173
  return;
126
174
  }
127
- current?.pause();
128
- other?.pause();
129
- if (frame) cancelAnimationFrame(frame);
130
- frame = 0;
131
- if (still.value && current && ready.value) current.currentTime = plan.value.from;
175
+ if (!running.value || pageHidden.value || still.value || !easing) {
176
+ rampTo(throttle.value, 0);
177
+ stopElements();
178
+ return;
179
+ }
180
+ rampTo(0, easing, () => {
181
+ if (!playing.value) stopElements();
182
+ });
132
183
  }
133
184
  watch(playing, sync, { flush: "post" });
134
- watch(() => props.rate, () => {
135
- setRate(first.value);
136
- setRate(second.value);
137
- });
185
+ watch(() => props.rate, applyRate);
138
186
  watch(
139
187
  () => props.src,
140
188
  () => {
@@ -170,14 +218,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
170
218
  }
171
219
  let motionQuery = null;
172
220
  onMounted(() => {
173
- setRate(first.value);
174
- setRate(second.value);
221
+ applyRate();
175
222
  motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
176
223
  motionQuery.addEventListener("change", onMotionChange);
177
224
  document.addEventListener("visibilitychange", onVisibility);
178
225
  });
179
226
  onBeforeUnmount(() => {
180
227
  if (frame) cancelAnimationFrame(frame);
228
+ if (ramp) cancelAnimationFrame(ramp);
181
229
  motionQuery?.removeEventListener("change", onMotionChange);
182
230
  document.removeEventListener("visibilitychange", onVisibility);
183
231
  });
@@ -190,7 +238,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
190
238
  };
191
239
  }
192
240
  const { class: rootClass, attrs: rest } = useMergedAttrs(
193
- "pointer-events-none absolute inset-0 overflow-hidden"
241
+ "pointer-events-none absolute inset-0 isolate overflow-hidden"
194
242
  );
195
243
  return (_ctx, _cache) => {
196
244
  return openBlock(), createElementBlock("div", mergeProps({
@@ -18,6 +18,8 @@ declare const __VLS_export: import("vue").DefineComponent<{
18
18
  paused?: boolean;
19
19
  /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
20
20
  playWithReducedMotion?: boolean;
21
+ /** Milliseconds over which a pause slows the picture to a halt and a resume brings it back up; 0 stops dead. */
22
+ easePlayback?: number;
21
23
  }, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
22
24
  error: () => any;
23
25
  loaded: (info: {
@@ -43,6 +45,8 @@ declare const __VLS_export: import("vue").DefineComponent<{
43
45
  paused?: boolean;
44
46
  /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
45
47
  playWithReducedMotion?: boolean;
48
+ /** Milliseconds over which a pause slows the picture to a halt and a resume brings it back up; 0 stops dead. */
49
+ easePlayback?: number;
46
50
  }> & Readonly<{
47
51
  onError?: (() => any) | undefined;
48
52
  onLoaded?: ((info: {
@@ -58,4 +62,5 @@ declare const __VLS_export: import("vue").DefineComponent<{
58
62
  rate: number;
59
63
  fit: "cover" | "contain" | "fill" | "none";
60
64
  playWithReducedMotion: boolean;
65
+ easePlayback: number;
61
66
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpVideoBackdrop-DN6xPUav.js";
1
+ import { _ as _sfc_main } from "../chunks/LpVideoBackdrop-qWRYBOTQ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -84,8 +84,8 @@ function useShift(options = {}) {
84
84
  settleTimer = setTimeout(() => {
85
85
  inFlight = false;
86
86
  resizing.value = false;
87
- node.style.height = "";
88
- node.style.width = "";
87
+ if (wantsHeight()) node.style.height = "";
88
+ if (wantsWidth()) node.style.width = "";
89
89
  lastHeight = node.offsetHeight;
90
90
  lastWidth = node.offsetWidth;
91
91
  }, wait);
@@ -122,8 +122,8 @@ function useShift(options = {}) {
122
122
  if (wantsWidth()) node.style.width = `${lastWidth}px`;
123
123
  raf = requestAnimationFrame(() => {
124
124
  tweening.value = true;
125
- node.style.height = "";
126
- node.style.width = "";
125
+ if (wantsHeight()) node.style.height = "";
126
+ if (wantsWidth()) node.style.width = "";
127
127
  });
128
128
  const watchChildren = () => {
129
129
  if (!ro) return;
package/dist/index.js CHANGED
@@ -87,7 +87,7 @@ import { _ as _61 } from "./chunks/LpSidebar-DpEiGi8L.js";
87
87
  import { _ as _62 } from "./chunks/LpSkeleton-BB6XIV-w.js";
88
88
  import { _ as _63 } from "./chunks/LpSlider-Cuzmrd7p.js";
89
89
  import { _ as _64 } from "./chunks/LpRangeSlider-C1yF9-nP.js";
90
- import { _ as _65 } from "./chunks/LpVideoBackdrop-DN6xPUav.js";
90
+ import { _ as _65 } from "./chunks/LpVideoBackdrop-qWRYBOTQ.js";
91
91
  import { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop.js";
92
92
  import { _ as _66 } from "./chunks/LpSparkline-CB80N8SJ.js";
93
93
  import { _ as _67 } from "./chunks/LpStat-B-7udBRP.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.48.0",
3
+ "version": "0.49.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -24,13 +24,16 @@ export default { inheritAttrs: false }
24
24
  * - Pausing where motion is unwanted: `paused`, a hidden tab, and the
25
25
  * system's reduced-motion setting, which shows the first frame of the
26
26
  * stretch instead (`playWithReducedMotion` overrides it).
27
+ * - Easing in and out of a pause (`easePlayback`): the picture slows to a
28
+ * standstill and gathers speed again instead of freezing and lurching. A
29
+ * hidden tab and reduced motion still stop at once.
27
30
  *
28
31
  * The second element exists only while there is a seam to blend, so a plain
29
32
  * loop decodes one stream. Both play the same `src`: a URL the media element
30
33
  * fetches by range, or an object URL of a file already in memory.
31
34
  */
32
35
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
33
- import { prefersReducedMotion } from "../composables/easing"
36
+ import { easeTravel, prefersReducedMotion } from "../composables/easing"
34
37
  import { useMergedAttrs } from "../composables/useMergedClass"
35
38
  import { isNativeLoop, planLoop, seamProgress, seamReached } from "./videoBackdrop"
36
39
 
@@ -53,8 +56,19 @@ const props = withDefaults(
53
56
  paused?: boolean
54
57
  /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
55
58
  playWithReducedMotion?: boolean
59
+ /** Milliseconds over which a pause slows the picture to a halt and a resume brings it back up; 0 stops dead. */
60
+ easePlayback?: number
56
61
  }>(),
57
- { start: 0, crossfade: 0, rate: 1, dim: 0, fit: "cover", paused: false, playWithReducedMotion: false },
62
+ {
63
+ start: 0,
64
+ crossfade: 0,
65
+ rate: 1,
66
+ dim: 0,
67
+ fit: "cover",
68
+ paused: false,
69
+ playWithReducedMotion: false,
70
+ easePlayback: 600,
71
+ },
58
72
  )
59
73
 
60
74
  const emit = defineEmits<{
@@ -74,6 +88,18 @@ const active = ref<0 | 1>(0)
74
88
  const incoming = ref<0 | 1 | null>(null)
75
89
  const incomingOpacity = ref(0)
76
90
 
91
+ /** The elements are playing, which includes slowing down into a pause. */
92
+ const running = ref(false)
93
+ /** Share of `rate` played at, 0 to 1: below 1 while easing into or out of a pause. */
94
+ const throttle = ref(1)
95
+
96
+ /**
97
+ * The slowest rate a pause eases down to before the element is paused.
98
+ * Chromium refuses a rate below a sixteenth; past that point the picture is
99
+ * all but still anyway.
100
+ */
101
+ const SLOWEST_RATE = 1 / 16
102
+
77
103
  const reducedMotion = ref(prefersReducedMotion())
78
104
  const pageHidden = ref(typeof document !== "undefined" && document.hidden)
79
105
 
@@ -93,7 +119,39 @@ function setRate(video: HTMLVideoElement | null) {
93
119
  if (!video) return
94
120
  // `defaultPlaybackRate` too: a reload of the source resets the rate to it.
95
121
  video.defaultPlaybackRate = props.rate
96
- video.playbackRate = props.rate
122
+ video.playbackRate = Math.max(SLOWEST_RATE, props.rate * throttle.value)
123
+ }
124
+
125
+ function applyRate() {
126
+ setRate(first.value)
127
+ setRate(second.value)
128
+ }
129
+
130
+ let ramp = 0
131
+ /** Move `throttle` to `target` over `ms`, eased, then call `done`. */
132
+ function rampTo(target: number, ms: number, done?: () => void) {
133
+ if (ramp) cancelAnimationFrame(ramp)
134
+ ramp = 0
135
+ const from = throttle.value
136
+ const span = ms * Math.abs(target - from)
137
+ if (span <= 0) {
138
+ throttle.value = target
139
+ applyRate()
140
+ done?.()
141
+ return
142
+ }
143
+ const began = performance.now()
144
+ const step = (now: number) => {
145
+ const k = Math.min(1, (now - began) / span)
146
+ throttle.value = from + (target - from) * easeTravel(k)
147
+ applyRate()
148
+ if (k < 1) ramp = requestAnimationFrame(step)
149
+ else {
150
+ ramp = 0
151
+ done?.()
152
+ }
153
+ }
154
+ ramp = requestAnimationFrame(step)
97
155
  }
98
156
 
99
157
  function onMetadata(event: Event) {
@@ -140,7 +198,7 @@ function finishSeam() {
140
198
  let frame = 0
141
199
  function watchFrames() {
142
200
  frame = 0
143
- if (!playing.value) return
201
+ if (!running.value) return
144
202
  const current = element(active.value)
145
203
  const loop = plan.value
146
204
  if (current && !native.value) {
@@ -167,28 +225,47 @@ function watchFrames() {
167
225
  frame = requestAnimationFrame(watchFrames)
168
226
  }
169
227
 
170
- function sync() {
171
- const current = element(active.value)
172
- const other = element(active.value === 0 ? 1 : 0)
173
- if (playing.value) {
174
- void current?.play().catch(() => {})
175
- if (incoming.value !== null) void other?.play().catch(() => {})
176
- if (!frame) frame = requestAnimationFrame(watchFrames)
177
- return
178
- }
179
- current?.pause()
180
- other?.pause()
228
+ function startElements() {
229
+ running.value = true
230
+ applyRate()
231
+ void element(active.value)?.play().catch(() => {})
232
+ if (incoming.value !== null) void element(incoming.value)?.play().catch(() => {})
233
+ if (!frame) frame = requestAnimationFrame(watchFrames)
234
+ }
235
+
236
+ function stopElements() {
237
+ running.value = false
238
+ first.value?.pause()
239
+ second.value?.pause()
181
240
  if (frame) cancelAnimationFrame(frame)
182
241
  frame = 0
183
242
  // A still shows the stretch's first frame, not wherever the clip stopped.
243
+ const current = element(active.value)
184
244
  if (still.value && current && ready.value) current.currentTime = plan.value.from
185
245
  }
186
246
 
247
+ function sync() {
248
+ const easing = props.easePlayback > 0 && !prefersReducedMotion() ? props.easePlayback : 0
249
+ if (playing.value) {
250
+ // A resume from a standstill gathers speed from the slowest rate.
251
+ if (!running.value) throttle.value = easing ? 0 : 1
252
+ startElements()
253
+ rampTo(1, easing)
254
+ return
255
+ }
256
+ // Nobody sees a hidden tab, and a still is asked for outright: both stop at once.
257
+ if (!running.value || pageHidden.value || still.value || !easing) {
258
+ rampTo(throttle.value, 0)
259
+ stopElements()
260
+ return
261
+ }
262
+ rampTo(0, easing, () => {
263
+ if (!playing.value) stopElements()
264
+ })
265
+ }
266
+
187
267
  watch(playing, sync, { flush: "post" })
188
- watch(() => props.rate, () => {
189
- setRate(first.value)
190
- setRate(second.value)
191
- })
268
+ watch(() => props.rate, applyRate)
192
269
  watch(
193
270
  () => props.src,
194
271
  () => {
@@ -230,8 +307,7 @@ function onVisibility() {
230
307
  let motionQuery: MediaQueryList | null = null
231
308
 
232
309
  onMounted(() => {
233
- setRate(first.value)
234
- setRate(second.value)
310
+ applyRate()
235
311
  motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
236
312
  motionQuery.addEventListener("change", onMotionChange)
237
313
  document.addEventListener("visibilitychange", onVisibility)
@@ -239,6 +315,7 @@ onMounted(() => {
239
315
 
240
316
  onBeforeUnmount(() => {
241
317
  if (frame) cancelAnimationFrame(frame)
318
+ if (ramp) cancelAnimationFrame(ramp)
242
319
  motionQuery?.removeEventListener("change", onMotionChange)
243
320
  document.removeEventListener("visibilitychange", onVisibility)
244
321
  })
@@ -252,8 +329,12 @@ function layerStyle(index: 0 | 1) {
252
329
  }
253
330
  }
254
331
 
332
+ // `isolate`: the element fading in at the seam is raised over the one going
333
+ // out with a z-index, and without a stacking context of its own here that
334
+ // z-index lifted it over the page's content too, so text laid on the backdrop
335
+ // faded away at every seam.
255
336
  const { class: rootClass, attrs: rest } = useMergedAttrs(
256
- "pointer-events-none absolute inset-0 overflow-hidden",
337
+ "pointer-events-none absolute inset-0 isolate overflow-hidden",
257
338
  )
258
339
  </script>
259
340
 
@@ -233,9 +233,12 @@ export function useShift(
233
233
  resizing.value = false
234
234
  // Released once the box has arrived. A size left pinned in pixels stops
235
235
  // being a starting point and becomes a cap: content that grows afterwards
236
- // is clipped, and `max-*` can no longer size the element itself.
237
- node.style.height = ""
238
- node.style.width = ""
236
+ // is clipped, and `max-*` can no longer size the element itself. Only
237
+ // the axes this composable sizes: the other may carry the host's own
238
+ // inline size (a dialog's `width` beside a height-only tween), and
239
+ // clearing it let the box spread to the window's edges.
240
+ if (wantsHeight()) node.style.height = ""
241
+ if (wantsWidth()) node.style.width = ""
239
242
  // Re-read rather than trusting the target: a `max-*` may have clamped the
240
243
  // box short of it, and the next tween has to start from where it sits.
241
244
  lastHeight = node.offsetHeight
@@ -284,8 +287,8 @@ export function useShift(
284
287
  // from, and holding it would cap the box at its opening size.
285
288
  raf = requestAnimationFrame(() => {
286
289
  tweening.value = true
287
- node.style.height = ""
288
- node.style.width = ""
290
+ if (wantsHeight()) node.style.height = ""
291
+ if (wantsWidth()) node.style.width = ""
289
292
  })
290
293
 
291
294
  // Two triggers, since either can move the size alone: the resize observer