@leavepulse/ui 0.47.0 → 0.49.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.
@@ -0,0 +1,86 @@
1
+ import { defineComponent, openBlock, createBlock, unref, mergeProps, withCtx, createVNode, createElementBlock, Fragment, renderList, normalizeClass, toDisplayString, createCommentVNode } from "vue";
2
+ import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
3
+ import { useFieldControl } from "../composables/useField.js";
4
+ import { useMergedAttrs } from "../composables/useMergedClass.js";
5
+ const __default__ = { inheritAttrs: false };
6
+ const _sfc_main = /* @__PURE__ */ defineComponent({
7
+ ...__default__,
8
+ __name: "LpRangeSlider",
9
+ props: {
10
+ modelValue: { default: () => [0, 100] },
11
+ min: { default: 0 },
12
+ max: { default: 100 },
13
+ step: { default: 1 },
14
+ minGap: { default: 0 },
15
+ disabled: { type: Boolean },
16
+ ariaLabels: {},
17
+ format: {}
18
+ },
19
+ emits: ["update:modelValue", "change"],
20
+ setup(__props, { emit: __emit }) {
21
+ const props = __props;
22
+ const emit = __emit;
23
+ function pair(v) {
24
+ return v && v.length >= 2 ? [v[0], v[1]] : null;
25
+ }
26
+ function onUpdate(v) {
27
+ const value = pair(v);
28
+ if (value) emit("update:modelValue", value);
29
+ }
30
+ function onCommit(v) {
31
+ const value = pair(v);
32
+ if (value) emit("change", value);
33
+ }
34
+ const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }));
35
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
36
+ "relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55"
37
+ );
38
+ const thumbClass = "group/thumb relative block size-4 rounded-pill border-2 border-brand bg-surface shadow outline-none transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hover:scale-100 motion-reduce:active:scale-100";
39
+ const labelClass = "pointer-events-none absolute bottom-full left-1/2 mb-1.5 -translate-x-1/2 translate-y-0.5 rounded-control bg-surface-raised px-1.5 py-0.5 text-[11px] whitespace-nowrap text-ink tabular-nums opacity-0 shadow-sm transition-[opacity,translate] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-hover/thumb:translate-y-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:translate-y-0 group-focus-visible/thumb:opacity-100 group-active/thumb:translate-y-0 group-active/thumb:opacity-100 motion-reduce:transition-none";
40
+ return (_ctx, _cache) => {
41
+ return openBlock(), createBlock(unref(SliderRoot), mergeProps({
42
+ "model-value": [props.modelValue[0], props.modelValue[1]],
43
+ min: __props.min,
44
+ max: __props.max,
45
+ step: __props.step,
46
+ "min-steps-between-thumbs": Math.round(__props.minGap / __props.step),
47
+ disabled: unref(field).disabled,
48
+ class: unref(rootClass)
49
+ }, unref(rest), {
50
+ "onUpdate:modelValue": onUpdate,
51
+ onValueCommit: onCommit
52
+ }), {
53
+ default: withCtx(() => [
54
+ createVNode(unref(SliderTrack), { class: "relative h-1.5 grow overflow-hidden rounded-pill bg-surface-soft" }, {
55
+ default: withCtx(() => [
56
+ createVNode(unref(SliderRange), { class: "absolute h-full rounded-pill bg-brand" })
57
+ ]),
58
+ _: 1
59
+ }),
60
+ (openBlock(), createElementBlock(Fragment, null, renderList([0, 1], (index) => {
61
+ return createVNode(unref(SliderThumb), {
62
+ key: index,
63
+ id: index === 0 ? unref(field).id : void 0,
64
+ "aria-label": __props.ariaLabels?.[index],
65
+ "aria-labelledby": __props.ariaLabels ? void 0 : unref(field).labelId,
66
+ "aria-describedby": unref(field).describedBy,
67
+ class: normalizeClass(thumbClass)
68
+ }, {
69
+ default: withCtx(() => [
70
+ __props.format ? (openBlock(), createElementBlock("span", {
71
+ key: 0,
72
+ class: normalizeClass(labelClass)
73
+ }, toDisplayString(__props.format(props.modelValue[index])), 1)) : createCommentVNode("", true)
74
+ ]),
75
+ _: 2
76
+ }, 1032, ["id", "aria-label", "aria-labelledby", "aria-describedby"]);
77
+ }), 64))
78
+ ]),
79
+ _: 1
80
+ }, 16, ["model-value", "min", "max", "step", "min-steps-between-thumbs", "disabled", "class"]);
81
+ };
82
+ }
83
+ });
84
+ export {
85
+ _sfc_main as _
86
+ };
@@ -0,0 +1,299 @@
1
+ import { defineComponent, ref, computed, watch, onMounted, onBeforeUnmount, openBlock, createElementBlock, mergeProps, unref, normalizeStyle, createCommentVNode, createElementVNode, normalizeClass } from "vue";
2
+ import { prefersReducedMotion, easeTravel } from "../composables/easing.js";
3
+ import { useMergedAttrs } from "../composables/useMergedClass.js";
4
+ import { planLoop, isNativeLoop, seamReached, seamProgress } from "../components/videoBackdrop.js";
5
+ const _hoisted_1 = ["src"];
6
+ const _hoisted_2 = ["src", "poster", "loop"];
7
+ const _hoisted_3 = ["src"];
8
+ const __default__ = { inheritAttrs: false };
9
+ const _sfc_main = /* @__PURE__ */ defineComponent({
10
+ ...__default__,
11
+ __name: "LpVideoBackdrop",
12
+ props: {
13
+ src: {},
14
+ poster: {},
15
+ start: { default: 0 },
16
+ end: {},
17
+ crossfade: { default: 0 },
18
+ rate: { default: 1 },
19
+ dim: { default: 0 },
20
+ fit: { default: "cover" },
21
+ paused: { type: Boolean, default: false },
22
+ playWithReducedMotion: { type: Boolean, default: false },
23
+ easePlayback: { default: 600 }
24
+ },
25
+ emits: ["loaded", "error"],
26
+ setup(__props, { emit: __emit }) {
27
+ const props = __props;
28
+ const emit = __emit;
29
+ const first = ref(null);
30
+ const second = ref(null);
31
+ const duration = ref(Number.NaN);
32
+ const ready = ref(false);
33
+ const active = ref(0);
34
+ const incoming = ref(null);
35
+ const incomingOpacity = ref(0);
36
+ const running = ref(false);
37
+ const throttle = ref(1);
38
+ const SLOWEST_RATE = 1 / 16;
39
+ const reducedMotion = ref(prefersReducedMotion());
40
+ const pageHidden = ref(typeof document !== "undefined" && document.hidden);
41
+ const plan = computed(
42
+ () => planLoop(duration.value, { start: props.start, end: props.end, crossfade: props.crossfade })
43
+ );
44
+ const native = computed(() => isNativeLoop(plan.value, duration.value));
45
+ const blended = computed(() => plan.value.fade > 0);
46
+ const still = computed(() => reducedMotion.value && !props.playWithReducedMotion);
47
+ const playing = computed(() => ready.value && !props.paused && !pageHidden.value && !still.value);
48
+ function element(index) {
49
+ return index === 0 ? first.value : second.value;
50
+ }
51
+ function setRate(video) {
52
+ if (!video) return;
53
+ video.defaultPlaybackRate = 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);
84
+ }
85
+ function onMetadata(event) {
86
+ const video = event.target;
87
+ if (video !== first.value) return;
88
+ duration.value = video.duration;
89
+ emit("loaded", { duration: video.duration, width: video.videoWidth, height: video.videoHeight });
90
+ }
91
+ function onFirstFrame(event) {
92
+ const video = event.target;
93
+ if (video !== first.value || ready.value) return;
94
+ if (video.currentTime < plan.value.from) video.currentTime = plan.value.from;
95
+ ready.value = true;
96
+ }
97
+ function onError(event) {
98
+ if (event.target === first.value) emit("error");
99
+ }
100
+ function onEnded(event) {
101
+ const video = event.target;
102
+ if (video !== element(active.value)) return;
103
+ video.currentTime = plan.value.from;
104
+ if (playing.value) void video.play().catch(() => {
105
+ });
106
+ }
107
+ function finishSeam() {
108
+ const done = incoming.value;
109
+ if (done === null) return;
110
+ const old = element(active.value);
111
+ active.value = done;
112
+ incoming.value = null;
113
+ incomingOpacity.value = 0;
114
+ if (old) {
115
+ old.pause();
116
+ old.currentTime = plan.value.from;
117
+ }
118
+ }
119
+ let frame = 0;
120
+ function watchFrames() {
121
+ frame = 0;
122
+ if (!running.value) return;
123
+ const current = element(active.value);
124
+ const loop = plan.value;
125
+ if (current && !native.value) {
126
+ if (current.currentTime < loop.from - 0.05) current.currentTime = loop.from;
127
+ if (!blended.value) {
128
+ if (current.currentTime >= loop.to) current.currentTime = loop.from;
129
+ } else if (incoming.value === null && seamReached(loop, current.currentTime)) {
130
+ const next = active.value === 0 ? 1 : 0;
131
+ const video = element(next);
132
+ if (video) {
133
+ setRate(video);
134
+ if (Math.abs(video.currentTime - loop.from) > 0.05) video.currentTime = loop.from;
135
+ incoming.value = next;
136
+ incomingOpacity.value = 0;
137
+ void video.play().catch(() => {
138
+ });
139
+ }
140
+ }
141
+ if (incoming.value !== null) {
142
+ const video = element(incoming.value);
143
+ incomingOpacity.value = video ? seamProgress(loop, video.currentTime) : 1;
144
+ if (incomingOpacity.value >= 1) finishSeam();
145
+ }
146
+ }
147
+ frame = requestAnimationFrame(watchFrames);
148
+ }
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;
164
+ const current = element(active.value);
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;
169
+ if (playing.value) {
170
+ if (!running.value) throttle.value = easing ? 0 : 1;
171
+ startElements();
172
+ rampTo(1, easing);
173
+ return;
174
+ }
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
+ });
183
+ }
184
+ watch(playing, sync, { flush: "post" });
185
+ watch(() => props.rate, applyRate);
186
+ watch(
187
+ () => props.src,
188
+ () => {
189
+ ready.value = false;
190
+ duration.value = Number.NaN;
191
+ active.value = 0;
192
+ incoming.value = null;
193
+ incomingOpacity.value = 0;
194
+ }
195
+ );
196
+ watch(blended, (on) => {
197
+ if (on) return;
198
+ const carrier = second.value;
199
+ if (active.value === 1 && carrier && first.value) first.value.currentTime = carrier.currentTime;
200
+ active.value = 0;
201
+ incoming.value = null;
202
+ incomingOpacity.value = 0;
203
+ sync();
204
+ });
205
+ watch(
206
+ () => [plan.value.from, plan.value.to],
207
+ ([from, to]) => {
208
+ finishSeam();
209
+ const current = element(active.value);
210
+ if (current && (current.currentTime < from || current.currentTime > to)) current.currentTime = from;
211
+ }
212
+ );
213
+ function onMotionChange(event) {
214
+ reducedMotion.value = event.matches;
215
+ }
216
+ function onVisibility() {
217
+ pageHidden.value = document.hidden;
218
+ }
219
+ let motionQuery = null;
220
+ onMounted(() => {
221
+ applyRate();
222
+ motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
223
+ motionQuery.addEventListener("change", onMotionChange);
224
+ document.addEventListener("visibilitychange", onVisibility);
225
+ });
226
+ onBeforeUnmount(() => {
227
+ if (frame) cancelAnimationFrame(frame);
228
+ if (ramp) cancelAnimationFrame(ramp);
229
+ motionQuery?.removeEventListener("change", onMotionChange);
230
+ document.removeEventListener("visibilitychange", onVisibility);
231
+ });
232
+ function layerStyle(index) {
233
+ const fading = incoming.value === index;
234
+ return {
235
+ objectFit: props.fit,
236
+ zIndex: fading ? 1 : 0,
237
+ opacity: fading ? incomingOpacity.value : index === active.value ? 1 : 0
238
+ };
239
+ }
240
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
241
+ "pointer-events-none absolute inset-0 isolate overflow-hidden"
242
+ );
243
+ return (_ctx, _cache) => {
244
+ return openBlock(), createElementBlock("div", mergeProps({
245
+ class: unref(rootClass),
246
+ "aria-hidden": "true"
247
+ }, unref(rest)), [
248
+ __props.poster ? (openBlock(), createElementBlock("img", {
249
+ key: 0,
250
+ class: "absolute inset-0 size-full",
251
+ style: normalizeStyle({ objectFit: __props.fit }),
252
+ src: __props.poster,
253
+ alt: ""
254
+ }, null, 12, _hoisted_1)) : createCommentVNode("", true),
255
+ createElementVNode("div", {
256
+ class: normalizeClass(["absolute inset-0 transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none", ready.value ? "opacity-100" : "opacity-0"])
257
+ }, [
258
+ createElementVNode("video", {
259
+ ref_key: "first",
260
+ ref: first,
261
+ class: "absolute inset-0 size-full",
262
+ style: normalizeStyle(layerStyle(0)),
263
+ src: __props.src,
264
+ poster: __props.poster,
265
+ loop: native.value,
266
+ muted: "",
267
+ playsinline: "",
268
+ preload: "auto",
269
+ disablepictureinpicture: "",
270
+ onLoadedmetadata: onMetadata,
271
+ onLoadeddata: onFirstFrame,
272
+ onEnded,
273
+ onError
274
+ }, null, 44, _hoisted_2),
275
+ blended.value ? (openBlock(), createElementBlock("video", {
276
+ key: 0,
277
+ ref_key: "second",
278
+ ref: second,
279
+ class: "absolute inset-0 size-full",
280
+ style: normalizeStyle(layerStyle(1)),
281
+ src: __props.src,
282
+ muted: "",
283
+ playsinline: "",
284
+ preload: "auto",
285
+ disablepictureinpicture: "",
286
+ onEnded
287
+ }, null, 44, _hoisted_3)) : createCommentVNode("", true)
288
+ ], 2),
289
+ createElementVNode("div", {
290
+ class: "absolute inset-0 bg-black transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none",
291
+ style: normalizeStyle({ opacity: Math.min(1, Math.max(0, __props.dim)) })
292
+ }, null, 4)
293
+ ], 16);
294
+ };
295
+ }
296
+ });
297
+ export {
298
+ _sfc_main as _
299
+ };
@@ -1 +1 @@
1
- export declare const COMPONENT_NAMES: readonly ["LayoutCanvas", "LpActionBar", "LpAddressInput", "LpAlert", "LpAppShell", "LpAutocomplete", "LpAvatar", "LpBadge", "LpBreadcrumbs", "LpButton", "LpCalendar", "LpCard", "LpChart", "LpCheckbox", "LpCodeBlock", "LpColorPicker", "LpCommandPalette", "LpConfirmDialog", "LpConfirmHost", "LpContextMenu", "LpDatePicker", "LpDateRangePicker", "LpDisclosure", "LpDivider", "LpDrawer", "LpDropdownMenu", "LpDropzone", "LpEmptyState", "LpFieldNotice", "LpFileTree", "LpFormField", "LpIcon", "LpImageEditor", "LpInfraNode", "LpInput", "LpKeys", "LpLightbox", "LpLink", "LpLogViewer", "LpMapPicker", "LpMediaGallery", "LpModal", "LpModalNav", "LpNotificationBell", "LpNumberField", "LpNumberFlow", "LpOtpInput", "LpPagination", "LpPaletteFacts", "LpPaletteHeader", "LpPalettePreview", "LpPaletteStats", "LpPaletteSwatches", "LpPasswordInput", "LpPhoneInput", "LpPie", "LpPopover", "LpProgress", "LpRadio", "LpRadioGroup", "LpRangeCalendar", "LpRollingText", "LpScrollArea", "LpSection", "LpSegmented", "LpSelect", "LpServiceNode", "LpShift", "LpShortcutsDialog", "LpSidebar", "LpSkeleton", "LpSlider", "LpSparkline", "LpStat", "LpStepper", "LpSwitch", "LpTable", "LpTableOfContents", "LpTabs", "LpTagsInput", "LpTextarea", "LpThemeProvider", "LpThemeSwitcher", "LpTilt", "LpTimeField", "LpToaster", "LpTooltip", "LpTooltipProvider", "LpTopologyCanvas", "LpUptimeBar"];
1
+ export declare const COMPONENT_NAMES: readonly ["LayoutCanvas", "LpActionBar", "LpAddressInput", "LpAlert", "LpAppShell", "LpAutocomplete", "LpAvatar", "LpBadge", "LpBreadcrumbs", "LpButton", "LpCalendar", "LpCard", "LpChart", "LpCheckbox", "LpCodeBlock", "LpColorPicker", "LpCommandPalette", "LpConfirmDialog", "LpConfirmHost", "LpContextMenu", "LpDatePicker", "LpDateRangePicker", "LpDisclosure", "LpDivider", "LpDrawer", "LpDropdownMenu", "LpDropzone", "LpEmptyState", "LpFieldNotice", "LpFileTree", "LpFormField", "LpIcon", "LpImageEditor", "LpInfraNode", "LpInput", "LpKeys", "LpLightbox", "LpLink", "LpLogViewer", "LpMapPicker", "LpMediaGallery", "LpModal", "LpModalNav", "LpNotificationBell", "LpNumberField", "LpNumberFlow", "LpOtpInput", "LpPagination", "LpPaletteFacts", "LpPaletteHeader", "LpPalettePreview", "LpPaletteStats", "LpPaletteSwatches", "LpPasswordInput", "LpPhoneInput", "LpPie", "LpPopover", "LpProgress", "LpRadio", "LpRadioGroup", "LpRangeCalendar", "LpRangeSlider", "LpRollingText", "LpScrollArea", "LpSection", "LpSegmented", "LpSelect", "LpServiceNode", "LpShift", "LpShortcutsDialog", "LpSidebar", "LpSkeleton", "LpSlider", "LpSparkline", "LpStat", "LpStepper", "LpSwitch", "LpTable", "LpTableOfContents", "LpTabs", "LpTagsInput", "LpTextarea", "LpThemeProvider", "LpThemeSwitcher", "LpTilt", "LpTimeField", "LpToaster", "LpTooltip", "LpTooltipProvider", "LpTopologyCanvas", "LpUptimeBar", "LpVideoBackdrop"];
@@ -0,0 +1,39 @@
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: import("vue").DefineComponent<{
4
+ modelValue?: [number, number];
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ /** The least distance the two ends keep between them, in model units. */
9
+ minGap?: number;
10
+ disabled?: boolean;
11
+ /** Accessible names for the two thumbs when there's no visible label. */
12
+ ariaLabels?: [string, string];
13
+ /** Label over each thumb while it is in use: `(value) => "1:05"`. */
14
+ format?: (value: number) => string;
15
+ }, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
16
+ change: (value: [number, number]) => any;
17
+ "update:modelValue": (value: [number, number]) => any;
18
+ }, string, import("vue").PublicProps, Readonly<{
19
+ modelValue?: [number, number];
20
+ min?: number;
21
+ max?: number;
22
+ step?: number;
23
+ /** The least distance the two ends keep between them, in model units. */
24
+ minGap?: number;
25
+ disabled?: boolean;
26
+ /** Accessible names for the two thumbs when there's no visible label. */
27
+ ariaLabels?: [string, string];
28
+ /** Label over each thumb while it is in use: `(value) => "1:05"`. */
29
+ format?: (value: number) => string;
30
+ }> & Readonly<{
31
+ onChange?: ((value: [number, number]) => any) | undefined;
32
+ "onUpdate:modelValue"?: ((value: [number, number]) => any) | undefined;
33
+ }>, {
34
+ max: number;
35
+ min: number;
36
+ step: number;
37
+ modelValue: [number, number];
38
+ minGap: number;
39
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -0,0 +1,4 @@
1
+ import { _ as _sfc_main } from "../chunks/LpRangeSlider-C1yF9-nP.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -0,0 +1,66 @@
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: import("vue").DefineComponent<{
4
+ src: string;
5
+ /** Shown until the first frame decodes, and instead of motion when it is unwanted. */
6
+ poster?: string;
7
+ /** Where the looped stretch begins, in seconds. */
8
+ start?: number;
9
+ /** Where it ends, in seconds; the clip's end when unset. */
10
+ end?: number;
11
+ /** Seconds of media over which the next pass fades in at the seam; 0 cuts straight back. */
12
+ crossfade?: number;
13
+ /** Playback rate: 0.5 is half speed. */
14
+ rate?: number;
15
+ /** A dark veil over the picture, 0 to 1, for text laid on top. */
16
+ dim?: number;
17
+ fit?: "cover" | "contain" | "fill" | "none";
18
+ paused?: boolean;
19
+ /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
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;
23
+ }, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
24
+ error: () => any;
25
+ loaded: (info: {
26
+ duration: number;
27
+ width: number;
28
+ height: number;
29
+ }) => any;
30
+ }, string, import("vue").PublicProps, Readonly<{
31
+ src: string;
32
+ /** Shown until the first frame decodes, and instead of motion when it is unwanted. */
33
+ poster?: string;
34
+ /** Where the looped stretch begins, in seconds. */
35
+ start?: number;
36
+ /** Where it ends, in seconds; the clip's end when unset. */
37
+ end?: number;
38
+ /** Seconds of media over which the next pass fades in at the seam; 0 cuts straight back. */
39
+ crossfade?: number;
40
+ /** Playback rate: 0.5 is half speed. */
41
+ rate?: number;
42
+ /** A dark veil over the picture, 0 to 1, for text laid on top. */
43
+ dim?: number;
44
+ fit?: "cover" | "contain" | "fill" | "none";
45
+ paused?: boolean;
46
+ /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
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;
50
+ }> & Readonly<{
51
+ onError?: (() => any) | undefined;
52
+ onLoaded?: ((info: {
53
+ duration: number;
54
+ width: number;
55
+ height: number;
56
+ }) => any) | undefined;
57
+ }>, {
58
+ dim: number;
59
+ crossfade: number;
60
+ start: number;
61
+ paused: boolean;
62
+ rate: number;
63
+ fit: "cover" | "contain" | "fill" | "none";
64
+ playWithReducedMotion: boolean;
65
+ easePlayback: number;
66
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -0,0 +1,4 @@
1
+ import { _ as _sfc_main } from "../chunks/LpVideoBackdrop-qWRYBOTQ.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -0,0 +1,37 @@
1
+ /** The stretch of a clip that loops, and how long two passes overlap at the seam. */
2
+ export interface LoopPlan {
3
+ /** Where every pass starts, in seconds of media time. */
4
+ from: number;
5
+ /** Where every pass ends, in seconds of media time. */
6
+ to: number;
7
+ /** Media seconds over which the next pass fades in over the ending one; 0 cuts. */
8
+ fade: number;
9
+ }
10
+ /**
11
+ * The loop a backdrop plays, from what was asked and what the clip has.
12
+ *
13
+ * `start` and `end` are clamped into the clip and put in order; a stretch
14
+ * shorter than `minLength` is widened from its start (or, at the clip's end,
15
+ * backwards), since a loop of a few frames reads as a flicker. The overlap
16
+ * is at most half the stretch: a seam longer than that would have the next
17
+ * pass begin before the current one had shown anything of its own.
18
+ */
19
+ export declare function planLoop(duration: number, options?: {
20
+ start?: number;
21
+ end?: number;
22
+ crossfade?: number;
23
+ minLength?: number;
24
+ }): LoopPlan;
25
+ /** Whether the pass at `time` has reached the point where the next one must begin. */
26
+ export declare function seamReached(plan: LoopPlan, time: number): boolean;
27
+ /**
28
+ * How far the incoming pass is through the seam, 0 to 1, from its own media
29
+ * time: it starts at `from` and is fully in once it has played `fade` seconds.
30
+ * Measured on the media clock rather than the wall clock, so the fade keeps
31
+ * pace with a playback rate and holds still while playback stalls.
32
+ */
33
+ export declare function seamProgress(plan: LoopPlan, incomingTime: number): number;
34
+ /** Whether the whole clip loops with nothing cut and no overlap, which the media element does natively. */
35
+ export declare function isNativeLoop(plan: LoopPlan, duration: number): boolean;
36
+ /** "1:05", "0:07.5": a position on a clip as a range slider labels it. */
37
+ export declare function formatClipTime(seconds: number, precise?: boolean): string;
@@ -0,0 +1,40 @@
1
+ function planLoop(duration, options = {}) {
2
+ if (!Number.isFinite(duration) || duration <= 0) return { from: 0, to: 0, fade: 0 };
3
+ const clamp = (value) => Math.min(duration, Math.max(0, value));
4
+ let from = clamp(options.start ?? 0);
5
+ let to = clamp(options.end ?? duration);
6
+ if (to < from) [from, to] = [to, from];
7
+ const minLength = Math.min(duration, Math.max(0, options.minLength ?? 0.5));
8
+ if (to - from < minLength) {
9
+ to = Math.min(duration, from + minLength);
10
+ from = Math.max(0, to - minLength);
11
+ }
12
+ const fade = Math.min(Math.max(0, options.crossfade ?? 0), (to - from) / 2);
13
+ return { from, to, fade };
14
+ }
15
+ function seamReached(plan, time) {
16
+ return plan.to > plan.from && time >= plan.to - plan.fade;
17
+ }
18
+ function seamProgress(plan, incomingTime) {
19
+ if (plan.fade <= 0) return 1;
20
+ return Math.min(1, Math.max(0, (incomingTime - plan.from) / plan.fade));
21
+ }
22
+ function isNativeLoop(plan, duration) {
23
+ return plan.fade === 0 && plan.from === 0 && plan.to >= duration;
24
+ }
25
+ function formatClipTime(seconds, precise = false) {
26
+ const safe = Math.max(0, Number.isFinite(seconds) ? seconds : 0);
27
+ const minutes = Math.floor(safe / 60);
28
+ const rest = safe - minutes * 60;
29
+ const whole = Math.floor(rest);
30
+ const tenth = Math.floor((rest - whole) * 10);
31
+ const secs = String(whole).padStart(2, "0");
32
+ return precise && tenth ? `${minutes}:${secs}.${tenth}` : `${minutes}:${secs}`;
33
+ }
34
+ export {
35
+ formatClipTime,
36
+ isNativeLoop,
37
+ planLoop,
38
+ seamProgress,
39
+ seamReached
40
+ };
package/dist/index.d.ts CHANGED
@@ -130,6 +130,10 @@ export { default as LpSidebar } from "./components/LpSidebar.vue";
130
130
  export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue";
131
131
  export { default as LpSkeleton } from "./components/LpSkeleton.vue";
132
132
  export { default as LpSlider } from "./components/LpSlider.vue";
133
+ export { default as LpRangeSlider } from "./components/LpRangeSlider.vue";
134
+ export { default as LpVideoBackdrop } from "./components/LpVideoBackdrop.vue";
135
+ export { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop";
136
+ export type { LoopPlan } from "./components/videoBackdrop";
133
137
  export { default as LpSparkline } from "./components/LpSparkline.vue";
134
138
  export { default as LpStat } from "./components/LpStat.vue";
135
139
  export { default as LpStepper } from "./components/LpStepper.vue";
package/dist/index.js CHANGED
@@ -86,24 +86,27 @@ import { default as default11 } from "./components/LpShortcutsDialog.vue.js";
86
86
  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
- import { _ as _64 } from "./chunks/LpSparkline-CB80N8SJ.js";
90
- import { _ as _65 } from "./chunks/LpStat-B-7udBRP.js";
91
- import { _ as _66 } from "./chunks/LpStepper-C7XiVKZj.js";
92
- import { _ as _67 } from "./chunks/LpSwitch-OrcOJX9m.js";
89
+ import { _ as _64 } from "./chunks/LpRangeSlider-C1yF9-nP.js";
90
+ import { _ as _65 } from "./chunks/LpVideoBackdrop-qWRYBOTQ.js";
91
+ import { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop.js";
92
+ import { _ as _66 } from "./chunks/LpSparkline-CB80N8SJ.js";
93
+ import { _ as _67 } from "./chunks/LpStat-B-7udBRP.js";
94
+ import { _ as _68 } from "./chunks/LpStepper-C7XiVKZj.js";
95
+ import { _ as _69 } from "./chunks/LpSwitch-OrcOJX9m.js";
93
96
  import { default as default12 } from "./components/LpTable.vue.js";
94
97
  import { compareValues } from "./components/tableSort.js";
95
- import { _ as _68 } from "./chunks/LpTableOfContents-BUcAfGPQ.js";
96
- import { _ as _69 } from "./chunks/LpTabs-29iW1XOx.js";
97
- import { _ as _70 } from "./chunks/LpTagsInput-D1SuqbhB.js";
98
+ import { _ as _70 } from "./chunks/LpTableOfContents-BUcAfGPQ.js";
99
+ import { _ as _71 } from "./chunks/LpTabs-29iW1XOx.js";
100
+ import { _ as _72 } from "./chunks/LpTagsInput-D1SuqbhB.js";
98
101
  import { addTags, splitTags } from "./components/tags.js";
99
- import { _ as _71 } from "./chunks/LpTextarea-uuScWo6-.js";
100
- import { _ as _72 } from "./chunks/LpThemeSwitcher-BbYFFT5w.js";
101
- import { _ as _73 } from "./chunks/LpTilt-B7KHX1zH.js";
102
- import { _ as _74 } from "./chunks/LpToaster-DTtWuKvq.js";
103
- import { _ as _75 } from "./chunks/LpTooltip-CXVKcOXT.js";
104
- import { _ as _76 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
105
- import { _ as _77 } from "./chunks/LpTopologyCanvas-6ozNrQKh.js";
106
- import { _ as _78 } from "./chunks/LpUptimeBar-ugcv25xj.js";
102
+ import { _ as _73 } from "./chunks/LpTextarea-uuScWo6-.js";
103
+ import { _ as _74 } from "./chunks/LpThemeSwitcher-BbYFFT5w.js";
104
+ import { _ as _75 } from "./chunks/LpTilt-B7KHX1zH.js";
105
+ import { _ as _76 } from "./chunks/LpToaster-DTtWuKvq.js";
106
+ import { _ as _77 } from "./chunks/LpTooltip-CXVKcOXT.js";
107
+ import { _ as _78 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
108
+ import { _ as _79 } from "./chunks/LpTopologyCanvas-6ozNrQKh.js";
109
+ import { _ as _80 } from "./chunks/LpUptimeBar-ugcv25xj.js";
107
110
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
108
111
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
109
112
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -132,7 +135,7 @@ import { useShift } from "./composables/useShift.js";
132
135
  import { useTilt } from "./composables/useTilt.js";
133
136
  import { useZoomPan } from "./composables/useZoomPan.js";
134
137
  import { useToast } from "./composables/useToast.js";
135
- import { _ as _79 } from "./chunks/LpThemeProvider-Be4r2TQX.js";
138
+ import { _ as _81 } from "./chunks/LpThemeProvider-Be4r2TQX.js";
136
139
  import { isInternalHref, resolveLink } from "./components/link.js";
137
140
  function defineTheme(base, overrides = {}) {
138
141
  const src = typeof base === "string" ? presets[base] : base;
@@ -234,6 +237,7 @@ export {
234
237
  _52 as LpRadio,
235
238
  _53 as LpRadioGroup,
236
239
  _23 as LpRangeCalendar,
240
+ _64 as LpRangeSlider,
237
241
  _54 as LpRollingText,
238
242
  _55 as LpScrollArea,
239
243
  _57 as LpSection,
@@ -245,24 +249,25 @@ export {
245
249
  _61 as LpSidebar,
246
250
  _62 as LpSkeleton,
247
251
  _63 as LpSlider,
248
- _64 as LpSparkline,
249
- _65 as LpStat,
250
- _66 as LpStepper,
251
- _67 as LpSwitch,
252
+ _66 as LpSparkline,
253
+ _67 as LpStat,
254
+ _68 as LpStepper,
255
+ _69 as LpSwitch,
252
256
  default12 as LpTable,
253
- _68 as LpTableOfContents,
254
- _69 as LpTabs,
255
- _70 as LpTagsInput,
256
- _71 as LpTextarea,
257
- _79 as LpThemeProvider,
258
- _72 as LpThemeSwitcher,
259
- _73 as LpTilt,
257
+ _70 as LpTableOfContents,
258
+ _71 as LpTabs,
259
+ _72 as LpTagsInput,
260
+ _73 as LpTextarea,
261
+ _81 as LpThemeProvider,
262
+ _74 as LpThemeSwitcher,
263
+ _75 as LpTilt,
260
264
  _43 as LpTimeField,
261
- _74 as LpToaster,
262
- _75 as LpTooltip,
263
- _76 as LpTooltipProvider,
264
- _77 as LpTopologyCanvas,
265
- _78 as LpUptimeBar,
265
+ _76 as LpToaster,
266
+ _77 as LpTooltip,
267
+ _78 as LpTooltipProvider,
268
+ _79 as LpTopologyCanvas,
269
+ _80 as LpUptimeBar,
270
+ _65 as LpVideoBackdrop,
266
271
  N as NO_BACKDROP_ATTR,
267
272
  OSM_TILES,
268
273
  T as THEME_COOKIE,
@@ -321,6 +326,7 @@ export {
321
326
  findHotkeyConflicts,
322
327
  flagEmoji,
323
328
  formatAddress,
329
+ formatClipTime,
324
330
  formatColor,
325
331
  formatModified,
326
332
  formatRange,
@@ -337,6 +343,7 @@ export {
337
343
  i2 as isBrokenBackdropEngine,
338
344
  isInternalHref,
339
345
  isLight,
346
+ isNativeLoop,
340
347
  layoutController,
341
348
  l as light,
342
349
  j as lime,
@@ -367,6 +374,7 @@ export {
367
374
  parsePaletteQuery,
368
375
  parseTheme,
369
376
  pendingHotkey,
377
+ planLoop,
370
378
  m as prefersDarkScheme,
371
379
  prefersReducedMotion,
372
380
  presets,
@@ -392,6 +400,8 @@ export {
392
400
  ringWindow,
393
401
  r as rose,
394
402
  sameRange,
403
+ seamProgress,
404
+ seamReached,
395
405
  sequenceProgress,
396
406
  serializeConfig,
397
407
  serializeLayout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -63,6 +63,7 @@ export const COMPONENT_NAMES = [
63
63
  "LpRadio",
64
64
  "LpRadioGroup",
65
65
  "LpRangeCalendar",
66
+ "LpRangeSlider",
66
67
  "LpRollingText",
67
68
  "LpScrollArea",
68
69
  "LpSection",
@@ -91,5 +92,6 @@ export const COMPONENT_NAMES = [
91
92
  "LpTooltip",
92
93
  "LpTooltipProvider",
93
94
  "LpTopologyCanvas",
94
- "LpUptimeBar"
95
+ "LpUptimeBar",
96
+ "LpVideoBackdrop"
95
97
  ] as const
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
8
+ <script setup lang="ts">
9
+ /*
10
+ * Two thumbs on one track: a range rather than a value. A price "from – to",
11
+ * a stretch of a clip to loop, a span of years. The model is `[low, high]`;
12
+ * the thumbs cannot cross, and `minGap` keeps them that many units apart.
13
+ * Each thumb can carry a label above it (`format`), shown while it is hovered,
14
+ * focused or dragged, so the numbers are readable without a second readout.
15
+ */
16
+ import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from "reka-ui"
17
+ import { useFieldControl } from "../composables/useField"
18
+ import { useMergedAttrs } from "../composables/useMergedClass"
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ modelValue?: [number, number]
23
+ min?: number
24
+ max?: number
25
+ step?: number
26
+ /** The least distance the two ends keep between them, in model units. */
27
+ minGap?: number
28
+ disabled?: boolean
29
+ /** Accessible names for the two thumbs when there's no visible label. */
30
+ ariaLabels?: [string, string]
31
+ /** Label over each thumb while it is in use: `(value) => "1:05"`. */
32
+ format?: (value: number) => string
33
+ }>(),
34
+ { modelValue: () => [0, 100], min: 0, max: 100, step: 1, minGap: 0 },
35
+ )
36
+
37
+ const emit = defineEmits<{
38
+ // fires live while dragging
39
+ (e: "update:modelValue", value: [number, number]): void
40
+ // fires once on release (reka valueCommit) — for commit-vs-preview splits
41
+ (e: "change", value: [number, number]): void
42
+ }>()
43
+
44
+ function pair(v: number[] | undefined): [number, number] | null {
45
+ return v && v.length >= 2 ? [v[0]!, v[1]!] : null
46
+ }
47
+ function onUpdate(v: number[] | undefined) {
48
+ const value = pair(v)
49
+ if (value) emit("update:modelValue", value)
50
+ }
51
+ function onCommit(v: number[] | undefined) {
52
+ const value = pair(v)
53
+ if (value) emit("change", value)
54
+ }
55
+
56
+ const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
57
+
58
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
59
+ "relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55",
60
+ )
61
+
62
+ const thumbClass =
63
+ "group/thumb relative block size-4 rounded-pill border-2 border-brand bg-surface shadow outline-none transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
64
+ const labelClass =
65
+ "pointer-events-none absolute bottom-full left-1/2 mb-1.5 -translate-x-1/2 translate-y-0.5 rounded-control bg-surface-raised px-1.5 py-0.5 text-[11px] whitespace-nowrap text-ink tabular-nums opacity-0 shadow-sm transition-[opacity,translate] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-hover/thumb:translate-y-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:translate-y-0 group-focus-visible/thumb:opacity-100 group-active/thumb:translate-y-0 group-active/thumb:opacity-100 motion-reduce:transition-none"
66
+ </script>
67
+
68
+ <template>
69
+ <SliderRoot
70
+ :model-value="[props.modelValue[0], props.modelValue[1]]"
71
+ :min="min"
72
+ :max="max"
73
+ :step="step"
74
+ :min-steps-between-thumbs="Math.round(minGap / step)"
75
+ :disabled="field.disabled"
76
+ :class="rootClass"
77
+ v-bind="rest"
78
+ @update:model-value="onUpdate"
79
+ @value-commit="onCommit"
80
+ >
81
+ <SliderTrack class="relative h-1.5 grow overflow-hidden rounded-pill bg-surface-soft">
82
+ <SliderRange class="absolute h-full rounded-pill bg-brand" />
83
+ </SliderTrack>
84
+ <SliderThumb
85
+ v-for="index in [0, 1] as const"
86
+ :key="index"
87
+ :id="index === 0 ? field.id : undefined"
88
+ :aria-label="ariaLabels?.[index]"
89
+ :aria-labelledby="ariaLabels ? undefined : field.labelId"
90
+ :aria-describedby="field.describedBy"
91
+ :class="thumbClass"
92
+ >
93
+ <span v-if="format" :class="labelClass">{{ format(props.modelValue[index]) }}</span>
94
+ </SliderThumb>
95
+ </SliderRoot>
96
+ </template>
@@ -0,0 +1,384 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
8
+ <script setup lang="ts">
9
+ /*
10
+ * A muted, looping video behind something: a page's hero, a panel, an app's
11
+ * wallpaper. It fills the box it is put in (position it, the backdrop is
12
+ * `absolute inset-0` inside it) and keeps out of the way of what sits on top:
13
+ * no controls, no pointer events, no sound.
14
+ *
15
+ * Over a plain `<video loop muted autoplay>` it adds what a backdrop needs:
16
+ *
17
+ * - A stretch of the clip to loop (`start`, `end`), so a recording's dull
18
+ * opening or closing seconds never show.
19
+ * - A seam without a jump (`crossfade`): a second element starts the next
20
+ * pass at `start` shortly before the current one ends and fades in over it,
21
+ * paced on its own media clock, so a clip whose last frame does not match
22
+ * its first still loops without a visible cut.
23
+ * - Speed (`rate`) and a darkening veil (`dim`) for text laid over it.
24
+ * - Pausing where motion is unwanted: `paused`, a hidden tab, and the
25
+ * system's reduced-motion setting, which shows the first frame of the
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.
30
+ *
31
+ * The second element exists only while there is a seam to blend, so a plain
32
+ * loop decodes one stream. Both play the same `src`: a URL the media element
33
+ * fetches by range, or an object URL of a file already in memory.
34
+ */
35
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
36
+ import { easeTravel, prefersReducedMotion } from "../composables/easing"
37
+ import { useMergedAttrs } from "../composables/useMergedClass"
38
+ import { isNativeLoop, planLoop, seamProgress, seamReached } from "./videoBackdrop"
39
+
40
+ const props = withDefaults(
41
+ defineProps<{
42
+ src: string
43
+ /** Shown until the first frame decodes, and instead of motion when it is unwanted. */
44
+ poster?: string
45
+ /** Where the looped stretch begins, in seconds. */
46
+ start?: number
47
+ /** Where it ends, in seconds; the clip's end when unset. */
48
+ end?: number
49
+ /** Seconds of media over which the next pass fades in at the seam; 0 cuts straight back. */
50
+ crossfade?: number
51
+ /** Playback rate: 0.5 is half speed. */
52
+ rate?: number
53
+ /** A dark veil over the picture, 0 to 1, for text laid on top. */
54
+ dim?: number
55
+ fit?: "cover" | "contain" | "fill" | "none"
56
+ paused?: boolean
57
+ /** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
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
61
+ }>(),
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
+ },
72
+ )
73
+
74
+ const emit = defineEmits<{
75
+ /** The clip's length and picture size, once known: what a trim control needs. */
76
+ (e: "loaded", info: { duration: number; width: number; height: number }): void
77
+ /** The clip could not be played (missing file, a codec this engine lacks). */
78
+ (e: "error"): void
79
+ }>()
80
+
81
+ const first = ref<HTMLVideoElement | null>(null)
82
+ const second = ref<HTMLVideoElement | null>(null)
83
+ const duration = ref(Number.NaN)
84
+ const ready = ref(false)
85
+ /** Which element carries the current pass. */
86
+ const active = ref<0 | 1>(0)
87
+ /** The element fading in over it at the seam, and how far it has come. */
88
+ const incoming = ref<0 | 1 | null>(null)
89
+ const incomingOpacity = ref(0)
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
+
103
+ const reducedMotion = ref(prefersReducedMotion())
104
+ const pageHidden = ref(typeof document !== "undefined" && document.hidden)
105
+
106
+ const plan = computed(() =>
107
+ planLoop(duration.value, { start: props.start, end: props.end, crossfade: props.crossfade }),
108
+ )
109
+ const native = computed(() => isNativeLoop(plan.value, duration.value))
110
+ const blended = computed(() => plan.value.fade > 0)
111
+ const still = computed(() => reducedMotion.value && !props.playWithReducedMotion)
112
+ const playing = computed(() => ready.value && !props.paused && !pageHidden.value && !still.value)
113
+
114
+ function element(index: 0 | 1) {
115
+ return index === 0 ? first.value : second.value
116
+ }
117
+
118
+ function setRate(video: HTMLVideoElement | null) {
119
+ if (!video) return
120
+ // `defaultPlaybackRate` too: a reload of the source resets the rate to it.
121
+ video.defaultPlaybackRate = 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)
155
+ }
156
+
157
+ function onMetadata(event: Event) {
158
+ const video = event.target as HTMLVideoElement
159
+ if (video !== first.value) return
160
+ duration.value = video.duration
161
+ emit("loaded", { duration: video.duration, width: video.videoWidth, height: video.videoHeight })
162
+ }
163
+
164
+ function onFirstFrame(event: Event) {
165
+ const video = event.target as HTMLVideoElement
166
+ if (video !== first.value || ready.value) return
167
+ // A clip whose stretch starts later is moved there before it shows.
168
+ if (video.currentTime < plan.value.from) video.currentTime = plan.value.from
169
+ ready.value = true
170
+ }
171
+
172
+ function onError(event: Event) {
173
+ if (event.target === first.value) emit("error")
174
+ }
175
+
176
+ /** The pass ran out without the seam being caught (frames are not watched in a hidden tab). */
177
+ function onEnded(event: Event) {
178
+ const video = event.target as HTMLVideoElement
179
+ if (video !== element(active.value)) return
180
+ video.currentTime = plan.value.from
181
+ if (playing.value) void video.play().catch(() => {})
182
+ }
183
+
184
+ function finishSeam() {
185
+ const done = incoming.value
186
+ if (done === null) return
187
+ const old = element(active.value)
188
+ active.value = done
189
+ incoming.value = null
190
+ incomingOpacity.value = 0
191
+ if (old) {
192
+ old.pause()
193
+ // Parked at the start, so the next seam begins on a decoded frame.
194
+ old.currentTime = plan.value.from
195
+ }
196
+ }
197
+
198
+ let frame = 0
199
+ function watchFrames() {
200
+ frame = 0
201
+ if (!running.value) return
202
+ const current = element(active.value)
203
+ const loop = plan.value
204
+ if (current && !native.value) {
205
+ if (current.currentTime < loop.from - 0.05) current.currentTime = loop.from
206
+ if (!blended.value) {
207
+ if (current.currentTime >= loop.to) current.currentTime = loop.from
208
+ } else if (incoming.value === null && seamReached(loop, current.currentTime)) {
209
+ const next: 0 | 1 = active.value === 0 ? 1 : 0
210
+ const video = element(next)
211
+ if (video) {
212
+ setRate(video)
213
+ if (Math.abs(video.currentTime - loop.from) > 0.05) video.currentTime = loop.from
214
+ incoming.value = next
215
+ incomingOpacity.value = 0
216
+ void video.play().catch(() => {})
217
+ }
218
+ }
219
+ if (incoming.value !== null) {
220
+ const video = element(incoming.value)
221
+ incomingOpacity.value = video ? seamProgress(loop, video.currentTime) : 1
222
+ if (incomingOpacity.value >= 1) finishSeam()
223
+ }
224
+ }
225
+ frame = requestAnimationFrame(watchFrames)
226
+ }
227
+
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()
240
+ if (frame) cancelAnimationFrame(frame)
241
+ frame = 0
242
+ // A still shows the stretch's first frame, not wherever the clip stopped.
243
+ const current = element(active.value)
244
+ if (still.value && current && ready.value) current.currentTime = plan.value.from
245
+ }
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
+
267
+ watch(playing, sync, { flush: "post" })
268
+ watch(() => props.rate, applyRate)
269
+ watch(
270
+ () => props.src,
271
+ () => {
272
+ ready.value = false
273
+ duration.value = Number.NaN
274
+ active.value = 0
275
+ incoming.value = null
276
+ incomingOpacity.value = 0
277
+ },
278
+ )
279
+ // With the seam turned off the second element unmounts; if it was carrying
280
+ // the pass, the first takes over from the same moment before it goes.
281
+ watch(blended, (on) => {
282
+ if (on) return
283
+ const carrier = second.value
284
+ if (active.value === 1 && carrier && first.value) first.value.currentTime = carrier.currentTime
285
+ active.value = 0
286
+ incoming.value = null
287
+ incomingOpacity.value = 0
288
+ sync()
289
+ })
290
+
291
+ // A new stretch takes effect at once rather than at the next seam.
292
+ watch(
293
+ () => [plan.value.from, plan.value.to] as const,
294
+ ([from, to]) => {
295
+ finishSeam()
296
+ const current = element(active.value)
297
+ if (current && (current.currentTime < from || current.currentTime > to)) current.currentTime = from
298
+ },
299
+ )
300
+
301
+ function onMotionChange(event: MediaQueryListEvent) {
302
+ reducedMotion.value = event.matches
303
+ }
304
+ function onVisibility() {
305
+ pageHidden.value = document.hidden
306
+ }
307
+ let motionQuery: MediaQueryList | null = null
308
+
309
+ onMounted(() => {
310
+ applyRate()
311
+ motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
312
+ motionQuery.addEventListener("change", onMotionChange)
313
+ document.addEventListener("visibilitychange", onVisibility)
314
+ })
315
+
316
+ onBeforeUnmount(() => {
317
+ if (frame) cancelAnimationFrame(frame)
318
+ if (ramp) cancelAnimationFrame(ramp)
319
+ motionQuery?.removeEventListener("change", onMotionChange)
320
+ document.removeEventListener("visibilitychange", onVisibility)
321
+ })
322
+
323
+ function layerStyle(index: 0 | 1) {
324
+ const fading = incoming.value === index
325
+ return {
326
+ objectFit: props.fit,
327
+ zIndex: fading ? 1 : 0,
328
+ opacity: fading ? incomingOpacity.value : index === active.value ? 1 : 0,
329
+ }
330
+ }
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.
336
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
337
+ "pointer-events-none absolute inset-0 isolate overflow-hidden",
338
+ )
339
+ </script>
340
+
341
+ <template>
342
+ <div :class="rootClass" aria-hidden="true" v-bind="rest">
343
+ <!-- Under the video, which fades in over it: removing a still laid on top
344
+ would flash the page between the two. -->
345
+ <img v-if="poster" class="absolute inset-0 size-full" :style="{ objectFit: fit }" :src="poster" alt="" />
346
+ <div
347
+ class="absolute inset-0 transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
348
+ :class="ready ? 'opacity-100' : 'opacity-0'"
349
+ >
350
+ <video
351
+ ref="first"
352
+ class="absolute inset-0 size-full"
353
+ :style="layerStyle(0)"
354
+ :src="src"
355
+ :poster="poster"
356
+ :loop="native"
357
+ muted
358
+ playsinline
359
+ preload="auto"
360
+ disablepictureinpicture
361
+ @loadedmetadata="onMetadata"
362
+ @loadeddata="onFirstFrame"
363
+ @ended="onEnded"
364
+ @error="onError"
365
+ />
366
+ <video
367
+ v-if="blended"
368
+ ref="second"
369
+ class="absolute inset-0 size-full"
370
+ :style="layerStyle(1)"
371
+ :src="src"
372
+ muted
373
+ playsinline
374
+ preload="auto"
375
+ disablepictureinpicture
376
+ @ended="onEnded"
377
+ />
378
+ </div>
379
+ <div
380
+ class="absolute inset-0 bg-black transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
381
+ :style="{ opacity: Math.min(1, Math.max(0, dim)) }"
382
+ />
383
+ </div>
384
+ </template>
@@ -0,0 +1,73 @@
1
+ /*
2
+ * The parts of LpVideoBackdrop that are plain arithmetic, kept out of the SFC
3
+ * so they can be tested without a media element.
4
+ */
5
+
6
+ /** The stretch of a clip that loops, and how long two passes overlap at the seam. */
7
+ export interface LoopPlan {
8
+ /** Where every pass starts, in seconds of media time. */
9
+ from: number
10
+ /** Where every pass ends, in seconds of media time. */
11
+ to: number
12
+ /** Media seconds over which the next pass fades in over the ending one; 0 cuts. */
13
+ fade: number
14
+ }
15
+
16
+ /**
17
+ * The loop a backdrop plays, from what was asked and what the clip has.
18
+ *
19
+ * `start` and `end` are clamped into the clip and put in order; a stretch
20
+ * shorter than `minLength` is widened from its start (or, at the clip's end,
21
+ * backwards), since a loop of a few frames reads as a flicker. The overlap
22
+ * is at most half the stretch: a seam longer than that would have the next
23
+ * pass begin before the current one had shown anything of its own.
24
+ */
25
+ export function planLoop(
26
+ duration: number,
27
+ options: { start?: number; end?: number; crossfade?: number; minLength?: number } = {},
28
+ ): LoopPlan {
29
+ if (!Number.isFinite(duration) || duration <= 0) return { from: 0, to: 0, fade: 0 }
30
+ const clamp = (value: number) => Math.min(duration, Math.max(0, value))
31
+ let from = clamp(options.start ?? 0)
32
+ let to = clamp(options.end ?? duration)
33
+ if (to < from) [from, to] = [to, from]
34
+ const minLength = Math.min(duration, Math.max(0, options.minLength ?? 0.5))
35
+ if (to - from < minLength) {
36
+ to = Math.min(duration, from + minLength)
37
+ from = Math.max(0, to - minLength)
38
+ }
39
+ const fade = Math.min(Math.max(0, options.crossfade ?? 0), (to - from) / 2)
40
+ return { from, to, fade }
41
+ }
42
+
43
+ /** Whether the pass at `time` has reached the point where the next one must begin. */
44
+ export function seamReached(plan: LoopPlan, time: number): boolean {
45
+ return plan.to > plan.from && time >= plan.to - plan.fade
46
+ }
47
+
48
+ /**
49
+ * How far the incoming pass is through the seam, 0 to 1, from its own media
50
+ * time: it starts at `from` and is fully in once it has played `fade` seconds.
51
+ * Measured on the media clock rather than the wall clock, so the fade keeps
52
+ * pace with a playback rate and holds still while playback stalls.
53
+ */
54
+ export function seamProgress(plan: LoopPlan, incomingTime: number): number {
55
+ if (plan.fade <= 0) return 1
56
+ return Math.min(1, Math.max(0, (incomingTime - plan.from) / plan.fade))
57
+ }
58
+
59
+ /** Whether the whole clip loops with nothing cut and no overlap, which the media element does natively. */
60
+ export function isNativeLoop(plan: LoopPlan, duration: number): boolean {
61
+ return plan.fade === 0 && plan.from === 0 && plan.to >= duration
62
+ }
63
+
64
+ /** "1:05", "0:07.5": a position on a clip as a range slider labels it. */
65
+ export function formatClipTime(seconds: number, precise = false): string {
66
+ const safe = Math.max(0, Number.isFinite(seconds) ? seconds : 0)
67
+ const minutes = Math.floor(safe / 60)
68
+ const rest = safe - minutes * 60
69
+ const whole = Math.floor(rest)
70
+ const tenth = Math.floor((rest - whole) * 10)
71
+ const secs = String(whole).padStart(2, "0")
72
+ return precise && tenth ? `${minutes}:${secs}.${tenth}` : `${minutes}:${secs}`
73
+ }
package/src/index.ts CHANGED
@@ -180,6 +180,10 @@ export { default as LpSidebar } from "./components/LpSidebar.vue"
180
180
  export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
181
181
  export { default as LpSkeleton } from "./components/LpSkeleton.vue"
182
182
  export { default as LpSlider } from "./components/LpSlider.vue"
183
+ export { default as LpRangeSlider } from "./components/LpRangeSlider.vue"
184
+ export { default as LpVideoBackdrop } from "./components/LpVideoBackdrop.vue"
185
+ export { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop"
186
+ export type { LoopPlan } from "./components/videoBackdrop"
183
187
  export { default as LpSparkline } from "./components/LpSparkline.vue"
184
188
  export { default as LpStat } from "./components/LpStat.vue"
185
189
  export { default as LpStepper } from "./components/LpStepper.vue"