@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.
- package/dist/assets/HighlightText-BwC5xbqn.css +6 -0
- package/dist/assets/{LpCommandPalette-5miKYsb-.css → LpCommandPalette-DmQUsJqA.css} +21 -21
- package/dist/assets/LpKeys-BxRhfQda.css +36 -0
- package/dist/assets/LpPaletteFacts-B9FdVmIO.css +93 -0
- package/dist/assets/LpPaletteHeader-BVOu1UX5.css +70 -0
- package/dist/assets/LpPalettePreview-CJmAKgYW.css +43 -0
- package/dist/assets/LpPaletteStats-ClE-37MK.css +33 -0
- package/dist/assets/LpPaletteSwatches-CA0B8y2E.css +21 -0
- package/dist/assets/{LpShortcutsDialog-CqYX3yy6.css → LpShortcutsDialog-BUp_BX4s.css} +7 -28
- package/dist/assets/{LpTable-BoeA_TgK.css → LpTable-xWwI4KPZ.css} +17 -17
- package/dist/chunks/HighlightText-BvnYZkhi.js +27 -0
- package/dist/chunks/{LpAvatar-DeSb6H0c.js → LpAvatar-Cb1jtOVv.js} +21 -3
- package/dist/chunks/{LpLightbox-DSDahF23.js → LpLightbox-EVXzAq62.js} +3 -3
- package/dist/chunks/{LpMediaGallery-YKr-cQPN.js → LpMediaGallery-BnlkN_1f.js} +1 -1
- package/dist/chunks/LpPalettePreview-CFav_7co.js +19 -0
- 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/HighlightText.vue.d.ts +7 -0
- package/dist/components/HighlightText.vue.js +4 -0
- package/dist/components/LpAvatar.vue.d.ts +12 -0
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +152 -15
- package/dist/components/LpCommandPalette.vue.js +178 -92
- package/dist/components/LpKeys.vue.d.ts +22 -0
- package/dist/components/LpKeys.vue.js +52 -0
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.d.ts +25 -0
- package/dist/components/LpPaletteFacts.vue.js +85 -0
- package/dist/components/LpPaletteHeader.vue.d.ts +32 -0
- package/dist/components/LpPaletteHeader.vue.js +103 -0
- package/dist/components/LpPalettePreview.vue.d.ts +19 -0
- package/dist/components/LpPalettePreview.vue.js +4 -0
- package/dist/components/LpPaletteStats.vue.d.ts +11 -0
- package/dist/components/LpPaletteStats.vue.js +47 -0
- package/dist/components/LpPaletteSwatches.vue.d.ts +10 -0
- package/dist/components/LpPaletteSwatches.vue.js +34 -0
- package/dist/components/LpRangeSlider.vue.d.ts +39 -0
- package/dist/components/LpRangeSlider.vue.js +4 -0
- package/dist/components/LpShortcutsDialog.vue.js +8 -28
- package/dist/components/LpTable.vue.js +5 -3
- package/dist/components/LpVideoBackdrop.vue.d.ts +61 -0
- package/dist/components/LpVideoBackdrop.vue.js +4 -0
- package/dist/components/commandPalette.d.ts +157 -0
- package/dist/components/commandPalette.js +103 -0
- package/dist/components/commandSearch.d.ts +32 -0
- package/dist/components/commandSearch.js +48 -0
- package/dist/components/keys.d.ts +2 -0
- package/dist/components/keys.js +11 -0
- package/dist/components/videoBackdrop.d.ts +37 -0
- package/dist/components/videoBackdrop.js +40 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +167 -134
- package/package.json +1 -1
- package/src/component-names.ts +9 -1
- package/src/components/HighlightText.vue +28 -0
- package/src/components/LpAvatar.vue +21 -2
- package/src/components/LpCommandPalette.vue +387 -87
- package/src/components/LpKeys.vue +79 -0
- package/src/components/LpLightbox.vue +4 -1
- package/src/components/LpPaletteFacts.vue +165 -0
- package/src/components/LpPaletteHeader.vue +155 -0
- package/src/components/LpPalettePreview.vue +77 -0
- package/src/components/LpPaletteStats.vue +81 -0
- package/src/components/LpPaletteSwatches.vue +56 -0
- package/src/components/LpRangeSlider.vue +96 -0
- package/src/components/LpShortcutsDialog.vue +2 -45
- package/src/components/LpTable.vue +6 -1
- package/src/components/LpVideoBackdrop.vue +303 -0
- package/src/components/commandPalette.ts +264 -0
- package/src/components/commandSearch.ts +79 -0
- package/src/components/keys.ts +13 -0
- package/src/components/videoBackdrop.ts +73 -0
- 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
|
-
<
|
|
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
|
-
|
|
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>
|