@leavepulse/ui 0.46.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.
Files changed (75) hide show
  1. package/dist/assets/HighlightText-BwC5xbqn.css +6 -0
  2. package/dist/assets/{LpCommandPalette-5miKYsb-.css → LpCommandPalette-DmQUsJqA.css} +21 -21
  3. package/dist/assets/LpKeys-BxRhfQda.css +36 -0
  4. package/dist/assets/LpPaletteFacts-B9FdVmIO.css +93 -0
  5. package/dist/assets/LpPaletteHeader-BVOu1UX5.css +70 -0
  6. package/dist/assets/LpPalettePreview-CJmAKgYW.css +43 -0
  7. package/dist/assets/LpPaletteStats-ClE-37MK.css +33 -0
  8. package/dist/assets/LpPaletteSwatches-CA0B8y2E.css +21 -0
  9. package/dist/assets/{LpShortcutsDialog-CqYX3yy6.css → LpShortcutsDialog-BUp_BX4s.css} +7 -28
  10. package/dist/assets/{LpTable-BoeA_TgK.css → LpTable-xWwI4KPZ.css} +17 -17
  11. package/dist/chunks/HighlightText-BvnYZkhi.js +27 -0
  12. package/dist/chunks/{LpAvatar-DeSb6H0c.js → LpAvatar-Cb1jtOVv.js} +21 -3
  13. package/dist/chunks/{LpLightbox-DSDahF23.js → LpLightbox-EVXzAq62.js} +3 -3
  14. package/dist/chunks/{LpMediaGallery-YKr-cQPN.js → LpMediaGallery-BnlkN_1f.js} +1 -1
  15. package/dist/chunks/LpPalettePreview-CFav_7co.js +19 -0
  16. package/dist/chunks/LpRangeSlider-C1yF9-nP.js +86 -0
  17. package/dist/chunks/LpVideoBackdrop-DN6xPUav.js +251 -0
  18. package/dist/component-names.d.ts +1 -1
  19. package/dist/components/HighlightText.vue.d.ts +7 -0
  20. package/dist/components/HighlightText.vue.js +4 -0
  21. package/dist/components/LpAvatar.vue.d.ts +12 -0
  22. package/dist/components/LpAvatar.vue.js +1 -1
  23. package/dist/components/LpCommandPalette.vue.d.ts +152 -15
  24. package/dist/components/LpCommandPalette.vue.js +178 -92
  25. package/dist/components/LpKeys.vue.d.ts +22 -0
  26. package/dist/components/LpKeys.vue.js +52 -0
  27. package/dist/components/LpLightbox.vue.js +1 -1
  28. package/dist/components/LpMediaGallery.vue.js +1 -1
  29. package/dist/components/LpPaletteFacts.vue.d.ts +25 -0
  30. package/dist/components/LpPaletteFacts.vue.js +85 -0
  31. package/dist/components/LpPaletteHeader.vue.d.ts +32 -0
  32. package/dist/components/LpPaletteHeader.vue.js +103 -0
  33. package/dist/components/LpPalettePreview.vue.d.ts +19 -0
  34. package/dist/components/LpPalettePreview.vue.js +4 -0
  35. package/dist/components/LpPaletteStats.vue.d.ts +11 -0
  36. package/dist/components/LpPaletteStats.vue.js +47 -0
  37. package/dist/components/LpPaletteSwatches.vue.d.ts +10 -0
  38. package/dist/components/LpPaletteSwatches.vue.js +34 -0
  39. package/dist/components/LpRangeSlider.vue.d.ts +39 -0
  40. package/dist/components/LpRangeSlider.vue.js +4 -0
  41. package/dist/components/LpShortcutsDialog.vue.js +8 -28
  42. package/dist/components/LpTable.vue.js +5 -3
  43. package/dist/components/LpVideoBackdrop.vue.d.ts +61 -0
  44. package/dist/components/LpVideoBackdrop.vue.js +4 -0
  45. package/dist/components/commandPalette.d.ts +157 -0
  46. package/dist/components/commandPalette.js +103 -0
  47. package/dist/components/commandSearch.d.ts +32 -0
  48. package/dist/components/commandSearch.js +48 -0
  49. package/dist/components/keys.d.ts +2 -0
  50. package/dist/components/keys.js +11 -0
  51. package/dist/components/videoBackdrop.d.ts +37 -0
  52. package/dist/components/videoBackdrop.js +40 -0
  53. package/dist/index.d.ts +16 -3
  54. package/dist/index.js +167 -134
  55. package/package.json +1 -1
  56. package/src/component-names.ts +9 -1
  57. package/src/components/HighlightText.vue +28 -0
  58. package/src/components/LpAvatar.vue +21 -2
  59. package/src/components/LpCommandPalette.vue +387 -87
  60. package/src/components/LpKeys.vue +79 -0
  61. package/src/components/LpLightbox.vue +4 -1
  62. package/src/components/LpPaletteFacts.vue +165 -0
  63. package/src/components/LpPaletteHeader.vue +155 -0
  64. package/src/components/LpPalettePreview.vue +77 -0
  65. package/src/components/LpPaletteStats.vue +81 -0
  66. package/src/components/LpPaletteSwatches.vue +56 -0
  67. package/src/components/LpRangeSlider.vue +96 -0
  68. package/src/components/LpShortcutsDialog.vue +2 -45
  69. package/src/components/LpTable.vue +6 -1
  70. package/src/components/LpVideoBackdrop.vue +303 -0
  71. package/src/components/commandPalette.ts +264 -0
  72. package/src/components/commandSearch.ts +79 -0
  73. package/src/components/keys.ts +13 -0
  74. package/src/components/videoBackdrop.ts +73 -0
  75. package/src/index.ts +16 -3
@@ -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>
@@ -16,12 +16,11 @@
16
16
  * look for keys rather than only in the console.
17
17
  */
18
18
  import { computed } from "vue"
19
+ import LpKeys from "./LpKeys.vue"
19
20
  import LpModal from "./LpModal.vue"
20
21
  import {
21
22
  activeShortcuts,
22
- appleModifierLabel,
23
23
  mergeShortcutGroups,
24
- useAppleKeyboard,
25
24
  useHotkeys,
26
25
  type ShortcutGroup,
27
26
  } from "../composables/useHotkeys"
@@ -49,23 +48,9 @@ const props = withDefaults(
49
48
 
50
49
  const text = useLabels("shortcuts", {
51
50
  title: "Keyboard shortcuts",
52
- then: "then",
53
51
  conflict: "Also bound elsewhere: only one of them runs",
54
52
  group: "General",
55
53
  })
56
- const apple = useAppleKeyboard()
57
-
58
- /*
59
- * One key per "+", except a "+" that is the key itself: "Ctrl++" is Ctrl and
60
- * plus, not Ctrl and two blanks.
61
- */
62
- function keysOf(chord: string): string[] {
63
- return chord.split(/\+(?!$)/)
64
- }
65
-
66
- function stepsOf(combo: string): string[] {
67
- return combo.trim().split(/\s+/)
68
- }
69
54
 
70
55
  const shown = computed(() => (props.live ? mergeShortcutGroups(props.groups, activeShortcuts.value) : props.groups))
71
56
 
@@ -112,12 +97,7 @@ useHotkeys(() =>
112
97
  >!</span>
113
98
  </dt>
114
99
  <dd>
115
- <template v-for="(step, stepIndex) in stepsOf(entry.combo)" :key="`${step}-${stepIndex}`">
116
- <span v-if="stepIndex" class="shortcut-then">{{ text.then }}</span>
117
- <kbd v-for="(key, index) in keysOf(step)" :key="`${key}-${index}`">
118
- {{ appleModifierLabel(key.trim(), apple) }}
119
- </kbd>
120
- </template>
100
+ <LpKeys :combo="entry.combo" />
121
101
  </dd>
122
102
  </div>
123
103
  </dl>
@@ -181,33 +161,10 @@ useHotkeys(() =>
181
161
  cursor: help;
182
162
  }
183
163
 
184
- .shortcut-then {
185
- align-self: center;
186
- padding: 0 2px;
187
- color: var(--color-muted);
188
- font-size: 0.68rem;
189
- }
190
-
191
164
  .shortcut-row dd {
192
165
  display: flex;
193
166
  flex-shrink: 0;
194
167
  gap: 3px;
195
168
  margin: 0;
196
169
  }
197
-
198
- kbd {
199
- display: inline-flex;
200
- min-width: 20px;
201
- align-items: center;
202
- justify-content: center;
203
- padding: 2px 6px;
204
- border: 1px solid var(--color-line-strong);
205
- border-bottom-width: 2px;
206
- border-radius: 5px;
207
- background: color-mix(in srgb, var(--color-ink) 6%, transparent);
208
- color: var(--color-muted-strong);
209
- font-family: inherit;
210
- font-size: 0.7rem;
211
- line-height: 1.5;
212
- }
213
170
  </style>
@@ -909,7 +909,12 @@ const countText = computed(() => {
909
909
  })
910
910
  // A selectable table keeps the bar at all times (the row count until something
911
911
  // is selected): ticking the first row must not push the table down.
912
- const showBar = computed(() => props.summary || !!props.selectable || activeFilters.value.length > 0)
912
+ // As documented on `summary`: the bar shows on its own while filters are
913
+ // active or rows are selected, not merely because rows can be selected, which
914
+ // left a lone "75 rows" over every selectable table.
915
+ const showBar = computed(
916
+ () => props.summary || (!!props.selectable && props.selected.length > 0) || activeFilters.value.length > 0,
917
+ )
913
918
  const filteredOut = computed(() => {
914
919
  if (loading.value || failed.value || displayRows.value.length) return false
915
920
  if (props.load) return Object.values(hiddenValues.value).some((set) => set.size)
@@ -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>