@leavepulse/ui 0.47.0 → 0.48.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.
- package/dist/chunks/LpRangeSlider-C1yF9-nP.js +86 -0
- package/dist/chunks/LpVideoBackdrop-DN6xPUav.js +251 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpRangeSlider.vue.d.ts +39 -0
- package/dist/components/LpRangeSlider.vue.js +4 -0
- package/dist/components/LpVideoBackdrop.vue.d.ts +61 -0
- package/dist/components/LpVideoBackdrop.vue.js +4 -0
- package/dist/components/videoBackdrop.d.ts +37 -0
- package/dist/components/videoBackdrop.js +40 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +42 -32
- package/package.json +1 -1
- package/src/component-names.ts +3 -1
- package/src/components/LpRangeSlider.vue +96 -0
- package/src/components/LpVideoBackdrop.vue +303 -0
- package/src/components/videoBackdrop.ts +73 -0
- package/src/index.ts +4 -0
|
@@ -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,251 @@
|
|
|
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";
|
|
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
|
+
},
|
|
24
|
+
emits: ["loaded", "error"],
|
|
25
|
+
setup(__props, { emit: __emit }) {
|
|
26
|
+
const props = __props;
|
|
27
|
+
const emit = __emit;
|
|
28
|
+
const first = ref(null);
|
|
29
|
+
const second = ref(null);
|
|
30
|
+
const duration = ref(Number.NaN);
|
|
31
|
+
const ready = ref(false);
|
|
32
|
+
const active = ref(0);
|
|
33
|
+
const incoming = ref(null);
|
|
34
|
+
const incomingOpacity = ref(0);
|
|
35
|
+
const reducedMotion = ref(prefersReducedMotion());
|
|
36
|
+
const pageHidden = ref(typeof document !== "undefined" && document.hidden);
|
|
37
|
+
const plan = computed(
|
|
38
|
+
() => planLoop(duration.value, { start: props.start, end: props.end, crossfade: props.crossfade })
|
|
39
|
+
);
|
|
40
|
+
const native = computed(() => isNativeLoop(plan.value, duration.value));
|
|
41
|
+
const blended = computed(() => plan.value.fade > 0);
|
|
42
|
+
const still = computed(() => reducedMotion.value && !props.playWithReducedMotion);
|
|
43
|
+
const playing = computed(() => ready.value && !props.paused && !pageHidden.value && !still.value);
|
|
44
|
+
function element(index) {
|
|
45
|
+
return index === 0 ? first.value : second.value;
|
|
46
|
+
}
|
|
47
|
+
function setRate(video) {
|
|
48
|
+
if (!video) return;
|
|
49
|
+
video.defaultPlaybackRate = props.rate;
|
|
50
|
+
video.playbackRate = props.rate;
|
|
51
|
+
}
|
|
52
|
+
function onMetadata(event) {
|
|
53
|
+
const video = event.target;
|
|
54
|
+
if (video !== first.value) return;
|
|
55
|
+
duration.value = video.duration;
|
|
56
|
+
emit("loaded", { duration: video.duration, width: video.videoWidth, height: video.videoHeight });
|
|
57
|
+
}
|
|
58
|
+
function onFirstFrame(event) {
|
|
59
|
+
const video = event.target;
|
|
60
|
+
if (video !== first.value || ready.value) return;
|
|
61
|
+
if (video.currentTime < plan.value.from) video.currentTime = plan.value.from;
|
|
62
|
+
ready.value = true;
|
|
63
|
+
}
|
|
64
|
+
function onError(event) {
|
|
65
|
+
if (event.target === first.value) emit("error");
|
|
66
|
+
}
|
|
67
|
+
function onEnded(event) {
|
|
68
|
+
const video = event.target;
|
|
69
|
+
if (video !== element(active.value)) return;
|
|
70
|
+
video.currentTime = plan.value.from;
|
|
71
|
+
if (playing.value) void video.play().catch(() => {
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function finishSeam() {
|
|
75
|
+
const done = incoming.value;
|
|
76
|
+
if (done === null) return;
|
|
77
|
+
const old = element(active.value);
|
|
78
|
+
active.value = done;
|
|
79
|
+
incoming.value = null;
|
|
80
|
+
incomingOpacity.value = 0;
|
|
81
|
+
if (old) {
|
|
82
|
+
old.pause();
|
|
83
|
+
old.currentTime = plan.value.from;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let frame = 0;
|
|
87
|
+
function watchFrames() {
|
|
88
|
+
frame = 0;
|
|
89
|
+
if (!playing.value) return;
|
|
90
|
+
const current = element(active.value);
|
|
91
|
+
const loop = plan.value;
|
|
92
|
+
if (current && !native.value) {
|
|
93
|
+
if (current.currentTime < loop.from - 0.05) current.currentTime = loop.from;
|
|
94
|
+
if (!blended.value) {
|
|
95
|
+
if (current.currentTime >= loop.to) current.currentTime = loop.from;
|
|
96
|
+
} else if (incoming.value === null && seamReached(loop, current.currentTime)) {
|
|
97
|
+
const next = active.value === 0 ? 1 : 0;
|
|
98
|
+
const video = element(next);
|
|
99
|
+
if (video) {
|
|
100
|
+
setRate(video);
|
|
101
|
+
if (Math.abs(video.currentTime - loop.from) > 0.05) video.currentTime = loop.from;
|
|
102
|
+
incoming.value = next;
|
|
103
|
+
incomingOpacity.value = 0;
|
|
104
|
+
void video.play().catch(() => {
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (incoming.value !== null) {
|
|
109
|
+
const video = element(incoming.value);
|
|
110
|
+
incomingOpacity.value = video ? seamProgress(loop, video.currentTime) : 1;
|
|
111
|
+
if (incomingOpacity.value >= 1) finishSeam();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
frame = requestAnimationFrame(watchFrames);
|
|
115
|
+
}
|
|
116
|
+
function sync() {
|
|
117
|
+
const current = element(active.value);
|
|
118
|
+
const other = element(active.value === 0 ? 1 : 0);
|
|
119
|
+
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);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
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;
|
|
132
|
+
}
|
|
133
|
+
watch(playing, sync, { flush: "post" });
|
|
134
|
+
watch(() => props.rate, () => {
|
|
135
|
+
setRate(first.value);
|
|
136
|
+
setRate(second.value);
|
|
137
|
+
});
|
|
138
|
+
watch(
|
|
139
|
+
() => props.src,
|
|
140
|
+
() => {
|
|
141
|
+
ready.value = false;
|
|
142
|
+
duration.value = Number.NaN;
|
|
143
|
+
active.value = 0;
|
|
144
|
+
incoming.value = null;
|
|
145
|
+
incomingOpacity.value = 0;
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
watch(blended, (on) => {
|
|
149
|
+
if (on) return;
|
|
150
|
+
const carrier = second.value;
|
|
151
|
+
if (active.value === 1 && carrier && first.value) first.value.currentTime = carrier.currentTime;
|
|
152
|
+
active.value = 0;
|
|
153
|
+
incoming.value = null;
|
|
154
|
+
incomingOpacity.value = 0;
|
|
155
|
+
sync();
|
|
156
|
+
});
|
|
157
|
+
watch(
|
|
158
|
+
() => [plan.value.from, plan.value.to],
|
|
159
|
+
([from, to]) => {
|
|
160
|
+
finishSeam();
|
|
161
|
+
const current = element(active.value);
|
|
162
|
+
if (current && (current.currentTime < from || current.currentTime > to)) current.currentTime = from;
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
function onMotionChange(event) {
|
|
166
|
+
reducedMotion.value = event.matches;
|
|
167
|
+
}
|
|
168
|
+
function onVisibility() {
|
|
169
|
+
pageHidden.value = document.hidden;
|
|
170
|
+
}
|
|
171
|
+
let motionQuery = null;
|
|
172
|
+
onMounted(() => {
|
|
173
|
+
setRate(first.value);
|
|
174
|
+
setRate(second.value);
|
|
175
|
+
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
176
|
+
motionQuery.addEventListener("change", onMotionChange);
|
|
177
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
178
|
+
});
|
|
179
|
+
onBeforeUnmount(() => {
|
|
180
|
+
if (frame) cancelAnimationFrame(frame);
|
|
181
|
+
motionQuery?.removeEventListener("change", onMotionChange);
|
|
182
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
183
|
+
});
|
|
184
|
+
function layerStyle(index) {
|
|
185
|
+
const fading = incoming.value === index;
|
|
186
|
+
return {
|
|
187
|
+
objectFit: props.fit,
|
|
188
|
+
zIndex: fading ? 1 : 0,
|
|
189
|
+
opacity: fading ? incomingOpacity.value : index === active.value ? 1 : 0
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
193
|
+
"pointer-events-none absolute inset-0 overflow-hidden"
|
|
194
|
+
);
|
|
195
|
+
return (_ctx, _cache) => {
|
|
196
|
+
return openBlock(), createElementBlock("div", mergeProps({
|
|
197
|
+
class: unref(rootClass),
|
|
198
|
+
"aria-hidden": "true"
|
|
199
|
+
}, unref(rest)), [
|
|
200
|
+
__props.poster ? (openBlock(), createElementBlock("img", {
|
|
201
|
+
key: 0,
|
|
202
|
+
class: "absolute inset-0 size-full",
|
|
203
|
+
style: normalizeStyle({ objectFit: __props.fit }),
|
|
204
|
+
src: __props.poster,
|
|
205
|
+
alt: ""
|
|
206
|
+
}, null, 12, _hoisted_1)) : createCommentVNode("", true),
|
|
207
|
+
createElementVNode("div", {
|
|
208
|
+
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"])
|
|
209
|
+
}, [
|
|
210
|
+
createElementVNode("video", {
|
|
211
|
+
ref_key: "first",
|
|
212
|
+
ref: first,
|
|
213
|
+
class: "absolute inset-0 size-full",
|
|
214
|
+
style: normalizeStyle(layerStyle(0)),
|
|
215
|
+
src: __props.src,
|
|
216
|
+
poster: __props.poster,
|
|
217
|
+
loop: native.value,
|
|
218
|
+
muted: "",
|
|
219
|
+
playsinline: "",
|
|
220
|
+
preload: "auto",
|
|
221
|
+
disablepictureinpicture: "",
|
|
222
|
+
onLoadedmetadata: onMetadata,
|
|
223
|
+
onLoadeddata: onFirstFrame,
|
|
224
|
+
onEnded,
|
|
225
|
+
onError
|
|
226
|
+
}, null, 44, _hoisted_2),
|
|
227
|
+
blended.value ? (openBlock(), createElementBlock("video", {
|
|
228
|
+
key: 0,
|
|
229
|
+
ref_key: "second",
|
|
230
|
+
ref: second,
|
|
231
|
+
class: "absolute inset-0 size-full",
|
|
232
|
+
style: normalizeStyle(layerStyle(1)),
|
|
233
|
+
src: __props.src,
|
|
234
|
+
muted: "",
|
|
235
|
+
playsinline: "",
|
|
236
|
+
preload: "auto",
|
|
237
|
+
disablepictureinpicture: "",
|
|
238
|
+
onEnded
|
|
239
|
+
}, null, 44, _hoisted_3)) : createCommentVNode("", true)
|
|
240
|
+
], 2),
|
|
241
|
+
createElementVNode("div", {
|
|
242
|
+
class: "absolute inset-0 bg-black transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none",
|
|
243
|
+
style: normalizeStyle({ opacity: Math.min(1, Math.max(0, __props.dim)) })
|
|
244
|
+
}, null, 4)
|
|
245
|
+
], 16);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
export {
|
|
250
|
+
_sfc_main as _
|
|
251
|
+
};
|
|
@@ -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,61 @@
|
|
|
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
|
+
}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
|
|
22
|
+
error: () => any;
|
|
23
|
+
loaded: (info: {
|
|
24
|
+
duration: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}) => any;
|
|
28
|
+
}, string, import("vue").PublicProps, Readonly<{
|
|
29
|
+
src: string;
|
|
30
|
+
/** Shown until the first frame decodes, and instead of motion when it is unwanted. */
|
|
31
|
+
poster?: string;
|
|
32
|
+
/** Where the looped stretch begins, in seconds. */
|
|
33
|
+
start?: number;
|
|
34
|
+
/** Where it ends, in seconds; the clip's end when unset. */
|
|
35
|
+
end?: number;
|
|
36
|
+
/** Seconds of media over which the next pass fades in at the seam; 0 cuts straight back. */
|
|
37
|
+
crossfade?: number;
|
|
38
|
+
/** Playback rate: 0.5 is half speed. */
|
|
39
|
+
rate?: number;
|
|
40
|
+
/** A dark veil over the picture, 0 to 1, for text laid on top. */
|
|
41
|
+
dim?: number;
|
|
42
|
+
fit?: "cover" | "contain" | "fill" | "none";
|
|
43
|
+
paused?: boolean;
|
|
44
|
+
/** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
|
|
45
|
+
playWithReducedMotion?: boolean;
|
|
46
|
+
}> & Readonly<{
|
|
47
|
+
onError?: (() => any) | undefined;
|
|
48
|
+
onLoaded?: ((info: {
|
|
49
|
+
duration: number;
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
}) => any) | undefined;
|
|
53
|
+
}>, {
|
|
54
|
+
dim: number;
|
|
55
|
+
crossfade: number;
|
|
56
|
+
start: number;
|
|
57
|
+
paused: boolean;
|
|
58
|
+
rate: number;
|
|
59
|
+
fit: "cover" | "contain" | "fill" | "none";
|
|
60
|
+
playWithReducedMotion: boolean;
|
|
61
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
@@ -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/
|
|
90
|
-
import { _ as _65 } from "./chunks/
|
|
91
|
-
import {
|
|
92
|
-
import { _ as
|
|
89
|
+
import { _ as _64 } from "./chunks/LpRangeSlider-C1yF9-nP.js";
|
|
90
|
+
import { _ as _65 } from "./chunks/LpVideoBackdrop-DN6xPUav.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
|
|
96
|
-
import { _ as
|
|
97
|
-
import { _ as
|
|
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
|
|
100
|
-
import { _ as
|
|
101
|
-
import { _ as
|
|
102
|
-
import { _ as
|
|
103
|
-
import { _ as
|
|
104
|
-
import { _ as
|
|
105
|
-
import { _ as
|
|
106
|
-
import { _ as
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
_66 as LpSparkline,
|
|
253
|
+
_67 as LpStat,
|
|
254
|
+
_68 as LpStepper,
|
|
255
|
+
_69 as LpSwitch,
|
|
252
256
|
default12 as LpTable,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
package/src/component-names.ts
CHANGED
|
@@ -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,303 @@
|
|
|
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
|
+
*
|
|
28
|
+
* The second element exists only while there is a seam to blend, so a plain
|
|
29
|
+
* loop decodes one stream. Both play the same `src`: a URL the media element
|
|
30
|
+
* fetches by range, or an object URL of a file already in memory.
|
|
31
|
+
*/
|
|
32
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
33
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
34
|
+
import { useMergedAttrs } from "../composables/useMergedClass"
|
|
35
|
+
import { isNativeLoop, planLoop, seamProgress, seamReached } from "./videoBackdrop"
|
|
36
|
+
|
|
37
|
+
const props = withDefaults(
|
|
38
|
+
defineProps<{
|
|
39
|
+
src: string
|
|
40
|
+
/** Shown until the first frame decodes, and instead of motion when it is unwanted. */
|
|
41
|
+
poster?: string
|
|
42
|
+
/** Where the looped stretch begins, in seconds. */
|
|
43
|
+
start?: number
|
|
44
|
+
/** Where it ends, in seconds; the clip's end when unset. */
|
|
45
|
+
end?: number
|
|
46
|
+
/** Seconds of media over which the next pass fades in at the seam; 0 cuts straight back. */
|
|
47
|
+
crossfade?: number
|
|
48
|
+
/** Playback rate: 0.5 is half speed. */
|
|
49
|
+
rate?: number
|
|
50
|
+
/** A dark veil over the picture, 0 to 1, for text laid on top. */
|
|
51
|
+
dim?: number
|
|
52
|
+
fit?: "cover" | "contain" | "fill" | "none"
|
|
53
|
+
paused?: boolean
|
|
54
|
+
/** Keep playing when the system asks for reduced motion (it shows a still otherwise). */
|
|
55
|
+
playWithReducedMotion?: boolean
|
|
56
|
+
}>(),
|
|
57
|
+
{ start: 0, crossfade: 0, rate: 1, dim: 0, fit: "cover", paused: false, playWithReducedMotion: false },
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const emit = defineEmits<{
|
|
61
|
+
/** The clip's length and picture size, once known: what a trim control needs. */
|
|
62
|
+
(e: "loaded", info: { duration: number; width: number; height: number }): void
|
|
63
|
+
/** The clip could not be played (missing file, a codec this engine lacks). */
|
|
64
|
+
(e: "error"): void
|
|
65
|
+
}>()
|
|
66
|
+
|
|
67
|
+
const first = ref<HTMLVideoElement | null>(null)
|
|
68
|
+
const second = ref<HTMLVideoElement | null>(null)
|
|
69
|
+
const duration = ref(Number.NaN)
|
|
70
|
+
const ready = ref(false)
|
|
71
|
+
/** Which element carries the current pass. */
|
|
72
|
+
const active = ref<0 | 1>(0)
|
|
73
|
+
/** The element fading in over it at the seam, and how far it has come. */
|
|
74
|
+
const incoming = ref<0 | 1 | null>(null)
|
|
75
|
+
const incomingOpacity = ref(0)
|
|
76
|
+
|
|
77
|
+
const reducedMotion = ref(prefersReducedMotion())
|
|
78
|
+
const pageHidden = ref(typeof document !== "undefined" && document.hidden)
|
|
79
|
+
|
|
80
|
+
const plan = computed(() =>
|
|
81
|
+
planLoop(duration.value, { start: props.start, end: props.end, crossfade: props.crossfade }),
|
|
82
|
+
)
|
|
83
|
+
const native = computed(() => isNativeLoop(plan.value, duration.value))
|
|
84
|
+
const blended = computed(() => plan.value.fade > 0)
|
|
85
|
+
const still = computed(() => reducedMotion.value && !props.playWithReducedMotion)
|
|
86
|
+
const playing = computed(() => ready.value && !props.paused && !pageHidden.value && !still.value)
|
|
87
|
+
|
|
88
|
+
function element(index: 0 | 1) {
|
|
89
|
+
return index === 0 ? first.value : second.value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setRate(video: HTMLVideoElement | null) {
|
|
93
|
+
if (!video) return
|
|
94
|
+
// `defaultPlaybackRate` too: a reload of the source resets the rate to it.
|
|
95
|
+
video.defaultPlaybackRate = props.rate
|
|
96
|
+
video.playbackRate = props.rate
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function onMetadata(event: Event) {
|
|
100
|
+
const video = event.target as HTMLVideoElement
|
|
101
|
+
if (video !== first.value) return
|
|
102
|
+
duration.value = video.duration
|
|
103
|
+
emit("loaded", { duration: video.duration, width: video.videoWidth, height: video.videoHeight })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onFirstFrame(event: Event) {
|
|
107
|
+
const video = event.target as HTMLVideoElement
|
|
108
|
+
if (video !== first.value || ready.value) return
|
|
109
|
+
// A clip whose stretch starts later is moved there before it shows.
|
|
110
|
+
if (video.currentTime < plan.value.from) video.currentTime = plan.value.from
|
|
111
|
+
ready.value = true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onError(event: Event) {
|
|
115
|
+
if (event.target === first.value) emit("error")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The pass ran out without the seam being caught (frames are not watched in a hidden tab). */
|
|
119
|
+
function onEnded(event: Event) {
|
|
120
|
+
const video = event.target as HTMLVideoElement
|
|
121
|
+
if (video !== element(active.value)) return
|
|
122
|
+
video.currentTime = plan.value.from
|
|
123
|
+
if (playing.value) void video.play().catch(() => {})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function finishSeam() {
|
|
127
|
+
const done = incoming.value
|
|
128
|
+
if (done === null) return
|
|
129
|
+
const old = element(active.value)
|
|
130
|
+
active.value = done
|
|
131
|
+
incoming.value = null
|
|
132
|
+
incomingOpacity.value = 0
|
|
133
|
+
if (old) {
|
|
134
|
+
old.pause()
|
|
135
|
+
// Parked at the start, so the next seam begins on a decoded frame.
|
|
136
|
+
old.currentTime = plan.value.from
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let frame = 0
|
|
141
|
+
function watchFrames() {
|
|
142
|
+
frame = 0
|
|
143
|
+
if (!playing.value) return
|
|
144
|
+
const current = element(active.value)
|
|
145
|
+
const loop = plan.value
|
|
146
|
+
if (current && !native.value) {
|
|
147
|
+
if (current.currentTime < loop.from - 0.05) current.currentTime = loop.from
|
|
148
|
+
if (!blended.value) {
|
|
149
|
+
if (current.currentTime >= loop.to) current.currentTime = loop.from
|
|
150
|
+
} else if (incoming.value === null && seamReached(loop, current.currentTime)) {
|
|
151
|
+
const next: 0 | 1 = active.value === 0 ? 1 : 0
|
|
152
|
+
const video = element(next)
|
|
153
|
+
if (video) {
|
|
154
|
+
setRate(video)
|
|
155
|
+
if (Math.abs(video.currentTime - loop.from) > 0.05) video.currentTime = loop.from
|
|
156
|
+
incoming.value = next
|
|
157
|
+
incomingOpacity.value = 0
|
|
158
|
+
void video.play().catch(() => {})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (incoming.value !== null) {
|
|
162
|
+
const video = element(incoming.value)
|
|
163
|
+
incomingOpacity.value = video ? seamProgress(loop, video.currentTime) : 1
|
|
164
|
+
if (incomingOpacity.value >= 1) finishSeam()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
frame = requestAnimationFrame(watchFrames)
|
|
168
|
+
}
|
|
169
|
+
|
|
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()
|
|
181
|
+
if (frame) cancelAnimationFrame(frame)
|
|
182
|
+
frame = 0
|
|
183
|
+
// A still shows the stretch's first frame, not wherever the clip stopped.
|
|
184
|
+
if (still.value && current && ready.value) current.currentTime = plan.value.from
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
watch(playing, sync, { flush: "post" })
|
|
188
|
+
watch(() => props.rate, () => {
|
|
189
|
+
setRate(first.value)
|
|
190
|
+
setRate(second.value)
|
|
191
|
+
})
|
|
192
|
+
watch(
|
|
193
|
+
() => props.src,
|
|
194
|
+
() => {
|
|
195
|
+
ready.value = false
|
|
196
|
+
duration.value = Number.NaN
|
|
197
|
+
active.value = 0
|
|
198
|
+
incoming.value = null
|
|
199
|
+
incomingOpacity.value = 0
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
// With the seam turned off the second element unmounts; if it was carrying
|
|
203
|
+
// the pass, the first takes over from the same moment before it goes.
|
|
204
|
+
watch(blended, (on) => {
|
|
205
|
+
if (on) return
|
|
206
|
+
const carrier = second.value
|
|
207
|
+
if (active.value === 1 && carrier && first.value) first.value.currentTime = carrier.currentTime
|
|
208
|
+
active.value = 0
|
|
209
|
+
incoming.value = null
|
|
210
|
+
incomingOpacity.value = 0
|
|
211
|
+
sync()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// A new stretch takes effect at once rather than at the next seam.
|
|
215
|
+
watch(
|
|
216
|
+
() => [plan.value.from, plan.value.to] as const,
|
|
217
|
+
([from, to]) => {
|
|
218
|
+
finishSeam()
|
|
219
|
+
const current = element(active.value)
|
|
220
|
+
if (current && (current.currentTime < from || current.currentTime > to)) current.currentTime = from
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
function onMotionChange(event: MediaQueryListEvent) {
|
|
225
|
+
reducedMotion.value = event.matches
|
|
226
|
+
}
|
|
227
|
+
function onVisibility() {
|
|
228
|
+
pageHidden.value = document.hidden
|
|
229
|
+
}
|
|
230
|
+
let motionQuery: MediaQueryList | null = null
|
|
231
|
+
|
|
232
|
+
onMounted(() => {
|
|
233
|
+
setRate(first.value)
|
|
234
|
+
setRate(second.value)
|
|
235
|
+
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
236
|
+
motionQuery.addEventListener("change", onMotionChange)
|
|
237
|
+
document.addEventListener("visibilitychange", onVisibility)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
onBeforeUnmount(() => {
|
|
241
|
+
if (frame) cancelAnimationFrame(frame)
|
|
242
|
+
motionQuery?.removeEventListener("change", onMotionChange)
|
|
243
|
+
document.removeEventListener("visibilitychange", onVisibility)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
function layerStyle(index: 0 | 1) {
|
|
247
|
+
const fading = incoming.value === index
|
|
248
|
+
return {
|
|
249
|
+
objectFit: props.fit,
|
|
250
|
+
zIndex: fading ? 1 : 0,
|
|
251
|
+
opacity: fading ? incomingOpacity.value : index === active.value ? 1 : 0,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
256
|
+
"pointer-events-none absolute inset-0 overflow-hidden",
|
|
257
|
+
)
|
|
258
|
+
</script>
|
|
259
|
+
|
|
260
|
+
<template>
|
|
261
|
+
<div :class="rootClass" aria-hidden="true" v-bind="rest">
|
|
262
|
+
<!-- Under the video, which fades in over it: removing a still laid on top
|
|
263
|
+
would flash the page between the two. -->
|
|
264
|
+
<img v-if="poster" class="absolute inset-0 size-full" :style="{ objectFit: fit }" :src="poster" alt="" />
|
|
265
|
+
<div
|
|
266
|
+
class="absolute inset-0 transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
|
|
267
|
+
:class="ready ? 'opacity-100' : 'opacity-0'"
|
|
268
|
+
>
|
|
269
|
+
<video
|
|
270
|
+
ref="first"
|
|
271
|
+
class="absolute inset-0 size-full"
|
|
272
|
+
:style="layerStyle(0)"
|
|
273
|
+
:src="src"
|
|
274
|
+
:poster="poster"
|
|
275
|
+
:loop="native"
|
|
276
|
+
muted
|
|
277
|
+
playsinline
|
|
278
|
+
preload="auto"
|
|
279
|
+
disablepictureinpicture
|
|
280
|
+
@loadedmetadata="onMetadata"
|
|
281
|
+
@loadeddata="onFirstFrame"
|
|
282
|
+
@ended="onEnded"
|
|
283
|
+
@error="onError"
|
|
284
|
+
/>
|
|
285
|
+
<video
|
|
286
|
+
v-if="blended"
|
|
287
|
+
ref="second"
|
|
288
|
+
class="absolute inset-0 size-full"
|
|
289
|
+
:style="layerStyle(1)"
|
|
290
|
+
:src="src"
|
|
291
|
+
muted
|
|
292
|
+
playsinline
|
|
293
|
+
preload="auto"
|
|
294
|
+
disablepictureinpicture
|
|
295
|
+
@ended="onEnded"
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
<div
|
|
299
|
+
class="absolute inset-0 bg-black transition-opacity duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
|
|
300
|
+
:style="{ opacity: Math.min(1, Math.max(0, dim)) }"
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
</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"
|