@nexxtmove/ui 1.54.0 → 2.0.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/components/editor/molecules/EditorFontSize/EditorFontSize.vue.d.ts +2 -2
- package/dist/components/organisms/PickerPanel/PickerPanel.vue.d.ts +1 -1
- package/dist/components/organisms/VideoControls/VideoControls.vue.d.ts +77 -0
- package/dist/components/organisms/VideoPlayer/VideoPlayer.vue.d.ts +37 -18
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1840 -1454
- package/dist/nuxt.js +1 -0
- package/dist/ui.css +1 -1
- package/package.json +2 -2
- package/src/components/organisms/VideoControls/VideoControls.vue +961 -0
- package/src/components/organisms/VideoPlayer/VideoPlayer.vue +286 -252
- package/src/components.json +1 -0
- package/src/index.ts +11 -0
|
@@ -1,316 +1,350 @@
|
|
|
1
|
-
<script lang="ts"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { NexxtVideoControlsMessages } from '../VideoControls/VideoControls.vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `full`: the NexxtVideoControls bar; `toggle`: only its round play/pause button;
|
|
6
|
+
* `none`: nothing visible, and a click on the video is left to the parent.
|
|
7
|
+
*/
|
|
8
|
+
export type NexxtVideoPlayerControls = 'full' | 'toggle' | 'none'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Overridable copy: the player's own keys plus every key of NexxtVideoControlsMessages, which
|
|
12
|
+
* are passed on to the controls. Every key is optional; the Dutch defaults are merged per key.
|
|
13
|
+
*/
|
|
14
|
+
export interface NexxtVideoPlayerMessages extends NexxtVideoControlsMessages {
|
|
15
|
+
/** Shown when the video cannot be loaded. Defaults to `'De video kan niet worden geladen'`. */
|
|
16
|
+
error?: string
|
|
17
|
+
/** Accessible name of the video when no `name` is given. Defaults to `'Video'`. */
|
|
18
|
+
video?: string
|
|
19
|
+
}
|
|
5
20
|
|
|
6
|
-
|
|
21
|
+
export interface NexxtVideoPlayerProps {
|
|
22
|
+
/** URL of the video. */
|
|
7
23
|
src: string
|
|
24
|
+
/**
|
|
25
|
+
* Image shown before the video plays. With a poster nothing is preloaded (`preload="none"`);
|
|
26
|
+
* without one only the metadata is, so the first frame and the duration are there.
|
|
27
|
+
*/
|
|
28
|
+
poster?: string
|
|
29
|
+
/** The video's name: its accessible name and the name of the controls. */
|
|
30
|
+
name?: string
|
|
31
|
+
/** Start playing on mount. Browsers only allow that without sound, so pair it with `muted`. */
|
|
8
32
|
autoplay?: boolean
|
|
33
|
+
/** Start without sound. Applied before an autoplay starts. */
|
|
9
34
|
muted?: boolean
|
|
10
|
-
|
|
35
|
+
/** Start again at the end. */
|
|
11
36
|
loop?: boolean
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
enterFullscreen?: string
|
|
19
|
-
exitFullscreen?: string
|
|
20
|
-
seek?: string
|
|
21
|
-
video?: string
|
|
22
|
-
}
|
|
23
|
-
}>()
|
|
24
|
-
|
|
25
|
-
const labels = computed(() => ({
|
|
26
|
-
error: 'Video could not be loaded',
|
|
27
|
-
play: 'Play',
|
|
28
|
-
pause: 'Pause',
|
|
29
|
-
mute: 'Mute',
|
|
30
|
-
unmute: 'Unmute',
|
|
31
|
-
enterFullscreen: 'Enter fullscreen',
|
|
32
|
-
exitFullscreen: 'Exit fullscreen',
|
|
33
|
-
seek: 'Seek',
|
|
34
|
-
video: 'Video player',
|
|
35
|
-
...props.labels,
|
|
36
|
-
}))
|
|
37
|
+
/** Which controls to show. Defaults to `full`. */
|
|
38
|
+
controls?: NexxtVideoPlayerControls
|
|
39
|
+
/** Copy of the player and its controls. Defaults are Dutch and are merged per key. */
|
|
40
|
+
messages?: NexxtVideoPlayerMessages
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { useEventListener } from '@vueuse/core'
|
|
46
|
+
import { computed, nextTick, onMounted, ref, useAttrs, useTemplateRef, watch } from 'vue'
|
|
47
|
+
import NexxtIcon from '../../atoms/Icon/Icon.vue'
|
|
48
|
+
import NexxtVideoControls from '../VideoControls/VideoControls.vue'
|
|
49
|
+
|
|
50
|
+
// The attrs go to the root by hand, so the removed props below never land on it as HTML
|
|
51
|
+
defineOptions({ name: 'NexxtVideoPlayer', inheritAttrs: false })
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
src,
|
|
55
|
+
poster,
|
|
56
|
+
name = '',
|
|
57
|
+
autoplay = false,
|
|
58
|
+
muted: startMuted = false,
|
|
59
|
+
loop = false,
|
|
60
|
+
controls = 'full',
|
|
61
|
+
messages,
|
|
62
|
+
} = defineProps<NexxtVideoPlayerProps>()
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MESSAGES = {
|
|
65
|
+
error: 'De video kan niet worden geladen',
|
|
66
|
+
video: 'Video',
|
|
67
|
+
}
|
|
39
68
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
/** Props removed in the major that introduced `controls`, with what replaces them. */
|
|
70
|
+
const REMOVED_PROPS: Record<string, string> = {
|
|
71
|
+
showControls:
|
|
72
|
+
'`showControls` is removed: use `controls="none"` (was false) or `controls="full"` (was true).',
|
|
73
|
+
'show-controls':
|
|
74
|
+
'`showControls` is removed: use `controls="none"` (was false) or `controls="full"` (was true).',
|
|
75
|
+
labels: '`labels` is removed: use `messages` (Dutch defaults, merged per key).',
|
|
76
|
+
}
|
|
44
77
|
|
|
45
|
-
|
|
46
|
-
const isHovering = ref(false)
|
|
47
|
-
const overlayVisible = computed(() => !playing.value || isHovering.value)
|
|
78
|
+
const attrs = useAttrs()
|
|
48
79
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
watch(
|
|
52
|
-
() => props.src,
|
|
53
|
-
() => {
|
|
54
|
-
hasError.value = false
|
|
55
|
-
},
|
|
80
|
+
const rootAttrs = computed(() =>
|
|
81
|
+
Object.fromEntries(Object.entries(attrs).filter(([key]) => !Object.hasOwn(REMOVED_PROPS, key))),
|
|
56
82
|
)
|
|
57
83
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
// An old prop would otherwise fall through silently and bring the full bar back (the media
|
|
85
|
+
// library passed `show-controls="false"`). `process.env.NODE_ENV` stays in the library build,
|
|
86
|
+
// so the consumer's bundler decides whether this runs (as in NexxtTabs)
|
|
87
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
88
|
+
const removed = [
|
|
89
|
+
...new Set(
|
|
90
|
+
Object.keys(attrs)
|
|
91
|
+
.filter((key) => Object.hasOwn(REMOVED_PROPS, key))
|
|
92
|
+
.map((key) => REMOVED_PROPS[key]),
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
if (removed.length) console.warn(`[NexxtVideoPlayer] ${removed.join(' ')}`)
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
const bufferedPercent = computed(() =>
|
|
75
|
-
duration.value ? (endBuffer.value / duration.value) * 100 : 0,
|
|
76
|
-
)
|
|
98
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
99
|
+
const videoEl = useTemplateRef<HTMLVideoElement>('videoEl')
|
|
100
|
+
const controlsRef = useTemplateRef<InstanceType<typeof NexxtVideoControls>>('controlsRef')
|
|
101
|
+
const startButton = useTemplateRef<HTMLButtonElement>('startButton')
|
|
77
102
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
83
|
-
return `${m}:${String(s).padStart(2, '0')}`
|
|
84
|
-
}
|
|
103
|
+
const copy = computed(() => ({
|
|
104
|
+
error: messages?.error ?? DEFAULT_MESSAGES.error,
|
|
105
|
+
video: messages?.video ?? DEFAULT_MESSAGES.video,
|
|
106
|
+
}))
|
|
85
107
|
|
|
86
|
-
const
|
|
87
|
-
playing.value = !playing.value
|
|
88
|
-
triggerFlash(playing.value ? 'play' : 'pause')
|
|
89
|
-
}
|
|
90
|
-
const toggleMute = () => {
|
|
91
|
-
muted.value = !muted.value
|
|
92
|
-
}
|
|
108
|
+
const videoLabel = computed(() => name || copy.value.video)
|
|
93
109
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
const dragRatio = ref<number | null>(null)
|
|
98
|
-
let pendingSeek = false
|
|
99
|
-
|
|
100
|
-
const progressPct = computed(() =>
|
|
101
|
-
dragRatio.value !== null
|
|
102
|
-
? dragRatio.value * 100
|
|
103
|
-
: duration.value
|
|
104
|
-
? (currentTime.value / duration.value) * 100
|
|
105
|
-
: 0,
|
|
106
|
-
)
|
|
110
|
+
// With a poster there is nothing to show from the file until it plays; without one the
|
|
111
|
+
// metadata brings the first frame and the duration
|
|
112
|
+
const preload = computed(() => (poster ? 'none' : 'metadata'))
|
|
107
113
|
|
|
108
|
-
const
|
|
109
|
-
|
|
114
|
+
const hasError = ref(false)
|
|
115
|
+
const isPlaying = ref(false)
|
|
116
|
+
const isMuted = ref(startMuted)
|
|
117
|
+
const controlsVisible = ref(true)
|
|
118
|
+
/** The video has played at least once; the big play button is gone for good then. */
|
|
119
|
+
const hasStarted = ref(false)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Before the first play, with controls but no autoplay: a big play button over the poster. The
|
|
123
|
+
* controls wait behind it, hidden and inert, so it is the one tab stop. Never with `none`: the
|
|
124
|
+
* media library plays those on hover.
|
|
125
|
+
*/
|
|
126
|
+
const showStart = computed(
|
|
127
|
+
() => controls !== 'none' && !autoplay && !hasStarted.value && !hasError.value,
|
|
110
128
|
)
|
|
111
129
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
pendingSeek = false
|
|
116
|
-
dragRatio.value = null
|
|
117
|
-
}
|
|
130
|
+
const startLabel = computed(() => {
|
|
131
|
+
const label = messages?.play ?? 'Afspelen'
|
|
132
|
+
return name ? `${label}: ${name}` : label
|
|
118
133
|
})
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The controls unmount with the error without saying they are visible again, so the bar's state
|
|
137
|
+
* is reset here: otherwise a bar hidden at that moment leaves the cursor hidden over the message.
|
|
138
|
+
*/
|
|
139
|
+
const onError = () => {
|
|
140
|
+
hasError.value = true
|
|
141
|
+
controlsVisible.value = true
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
// The cursor goes with the bar while the video plays, and comes back on the first movement
|
|
145
|
+
const rootClasses = computed(() => ({
|
|
146
|
+
'cursor-none': controls !== 'none' && !controlsVisible.value,
|
|
147
|
+
}))
|
|
131
148
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
currentTime.value = next
|
|
137
|
-
}
|
|
149
|
+
/** Play, and swallow a refusal (autoplay with sound, an interrupted load): the events tell the rest. */
|
|
150
|
+
const play = () => {
|
|
151
|
+
const video = videoEl.value
|
|
152
|
+
if (!video) return
|
|
138
153
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
e.preventDefault()
|
|
145
|
-
seekBySeconds(5)
|
|
146
|
-
} else if (e.key === 'Home') {
|
|
147
|
-
e.preventDefault()
|
|
148
|
-
seekBySeconds(-Infinity)
|
|
149
|
-
} else if (e.key === 'End') {
|
|
150
|
-
e.preventDefault()
|
|
151
|
-
seekBySeconds(Infinity)
|
|
152
|
-
}
|
|
154
|
+
isPlaying.value = true
|
|
155
|
+
// `hasStarted` waits for the play event: a refused play must leave the big button standing
|
|
156
|
+
video.play().catch(() => {
|
|
157
|
+
isPlaying.value = !video.paused
|
|
158
|
+
})
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Whether the video plays. Writable: `true` plays, `false` pauses. Reads the intent at once,
|
|
163
|
+
* then follows the video's own events.
|
|
164
|
+
*/
|
|
165
|
+
const playing = computed({
|
|
166
|
+
get: () => isPlaying.value,
|
|
167
|
+
set: (value: boolean) => {
|
|
168
|
+
if (value) {
|
|
169
|
+
play()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
// Reads false at once, as `true` reads true at once; the pause event confirms it
|
|
173
|
+
isPlaying.value = false
|
|
174
|
+
videoEl.value?.pause()
|
|
175
|
+
},
|
|
176
|
+
})
|
|
164
177
|
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
/** Whether the sound is off. Writable. */
|
|
179
|
+
const mutedState = computed({
|
|
180
|
+
get: () => isMuted.value,
|
|
181
|
+
set: (value: boolean) => {
|
|
182
|
+
isMuted.value = value
|
|
183
|
+
if (videoEl.value) videoEl.value.muted = value
|
|
184
|
+
},
|
|
167
185
|
})
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
|
|
187
|
+
useEventListener(videoEl, 'play', () => {
|
|
188
|
+
isPlaying.value = true
|
|
189
|
+
hasStarted.value = true
|
|
170
190
|
})
|
|
171
|
-
useEventListener(
|
|
172
|
-
|
|
191
|
+
useEventListener(videoEl, ['pause', 'ended', 'emptied'], () => {
|
|
192
|
+
isPlaying.value = Boolean(videoEl.value && !videoEl.value.paused)
|
|
173
193
|
})
|
|
194
|
+
useEventListener(videoEl, 'volumechange', () => {
|
|
195
|
+
isMuted.value = videoEl.value?.muted ?? isMuted.value
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
watch(
|
|
199
|
+
() => startMuted,
|
|
200
|
+
(value) => {
|
|
201
|
+
mutedState.value = value
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
/** Muted first, then play: a browser only allows an autoplay without sound. */
|
|
206
|
+
const start = () => {
|
|
207
|
+
if (videoEl.value) videoEl.value.muted = isMuted.value
|
|
208
|
+
if (autoplay) play()
|
|
209
|
+
}
|
|
174
210
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
211
|
+
watch(
|
|
212
|
+
() => src,
|
|
213
|
+
async () => {
|
|
214
|
+
hasError.value = false
|
|
215
|
+
isPlaying.value = false
|
|
216
|
+
// A new video starts from its own begin state
|
|
217
|
+
hasStarted.value = false
|
|
218
|
+
await nextTick()
|
|
219
|
+
start()
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
onMounted(start)
|
|
224
|
+
|
|
225
|
+
/** How the big play button was used, if it was: `true` keyboard, `false` pointer. */
|
|
226
|
+
let startedByKeyboard: boolean | undefined
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The big play button: play. From the keyboard (a click without a pointer, `detail` 0) the
|
|
230
|
+
* focus that follows is keyboard focus and the bar stays; after a mouse click it is not, so the
|
|
231
|
+
* bar hides as usual.
|
|
232
|
+
*/
|
|
233
|
+
const onStart = (event: MouseEvent) => {
|
|
234
|
+
startedByKeyboard = event.detail === 0
|
|
235
|
+
play()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* When the video starts while the big button has the focus (by the button, by the parent via
|
|
240
|
+
* `playing`, by a click on the video), the focus goes to the controls' play button rather than
|
|
241
|
+
* to the body when the button leaves.
|
|
242
|
+
*/
|
|
243
|
+
watch(hasStarted, async (started) => {
|
|
244
|
+
const button = startButton.value
|
|
245
|
+
const byKeyboard = startedByKeyboard
|
|
246
|
+
startedByKeyboard = undefined
|
|
247
|
+
if (!started || !button || document.activeElement !== button) return
|
|
248
|
+
|
|
249
|
+
const focusVisible = byKeyboard ?? button.matches(':focus-visible')
|
|
250
|
+
// The controls stop being inert with this render; only then can they take the focus
|
|
251
|
+
await nextTick()
|
|
252
|
+
controlsRef.value?.focus({ focusVisible })
|
|
178
253
|
})
|
|
179
254
|
|
|
180
|
-
defineExpose({ playing, muted })
|
|
255
|
+
defineExpose({ playing, muted: mutedState })
|
|
181
256
|
</script>
|
|
182
257
|
|
|
183
258
|
<template>
|
|
184
259
|
<div
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
260
|
+
v-bind="rootAttrs"
|
|
261
|
+
ref="root"
|
|
262
|
+
data-video-player
|
|
263
|
+
class="relative isolate overflow-hidden"
|
|
264
|
+
:class="rootClasses"
|
|
188
265
|
>
|
|
189
266
|
<video
|
|
190
|
-
ref="
|
|
267
|
+
ref="videoEl"
|
|
191
268
|
:src="src"
|
|
269
|
+
:poster="poster"
|
|
270
|
+
:preload="preload"
|
|
192
271
|
:loop="loop"
|
|
193
|
-
|
|
272
|
+
playsinline
|
|
273
|
+
class="block w-full"
|
|
194
274
|
:class="{ invisible: hasError }"
|
|
195
|
-
:aria-label="
|
|
196
|
-
@error="
|
|
197
|
-
@click="showControls !== false ? togglePlay() : undefined"
|
|
275
|
+
:aria-label="videoLabel"
|
|
276
|
+
@error="onError"
|
|
198
277
|
/>
|
|
199
278
|
|
|
200
279
|
<div
|
|
201
280
|
v-if="hasError"
|
|
202
281
|
role="alert"
|
|
203
|
-
class="absolute inset-0 flex flex-col items-center justify-center gap-3
|
|
282
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-900 px-4 text-center text-white"
|
|
204
283
|
>
|
|
205
284
|
<NexxtIcon name="circle-exclamation" class="text-3xl text-red-400" aria-hidden="true" />
|
|
206
|
-
<p class="heading-4">{{
|
|
207
|
-
<p class="max-w-xs truncate extra-small-normal text-white/50">{{ src }}</p>
|
|
285
|
+
<p class="heading-4">{{ copy.error }}</p>
|
|
208
286
|
</div>
|
|
209
287
|
|
|
288
|
+
<NexxtVideoControls
|
|
289
|
+
v-else-if="controls !== 'none'"
|
|
290
|
+
ref="controlsRef"
|
|
291
|
+
:inert="showStart || undefined"
|
|
292
|
+
:class="{ invisible: showStart }"
|
|
293
|
+
:target="videoEl"
|
|
294
|
+
:fullscreen-target="root"
|
|
295
|
+
:variant="controls"
|
|
296
|
+
:name="name"
|
|
297
|
+
:messages="messages"
|
|
298
|
+
@visibility-change="controlsVisible = $event"
|
|
299
|
+
/>
|
|
300
|
+
|
|
210
301
|
<Transition
|
|
211
|
-
|
|
212
|
-
leave-active-class="transition-opacity duration-500 ease-in"
|
|
213
|
-
enter-from-class="opacity-0"
|
|
302
|
+
leave-active-class="transition-opacity duration-300 ease-in motion-reduce:transition-none"
|
|
214
303
|
leave-to-class="opacity-0"
|
|
215
304
|
>
|
|
216
305
|
<div
|
|
217
|
-
v-if="
|
|
218
|
-
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
306
|
+
v-if="showStart"
|
|
307
|
+
class="@container pointer-events-none absolute inset-0 flex items-center justify-center print:hidden"
|
|
219
308
|
>
|
|
220
|
-
<div
|
|
221
|
-
class="flex size-16 items-center justify-center rounded-full bg-black/50 text-white"
|
|
222
|
-
aria-hidden="true"
|
|
223
|
-
>
|
|
224
|
-
<NexxtIcon :name="flashIcon" class="text-2xl" />
|
|
225
|
-
</div>
|
|
226
|
-
</div>
|
|
227
|
-
</Transition>
|
|
228
|
-
|
|
229
|
-
<div
|
|
230
|
-
v-if="showControls !== false && !hasError"
|
|
231
|
-
class="absolute inset-x-0 bottom-0 flex flex-col gap-2 bg-linear-to-t from-black/70 via-black/40 to-transparent mask-[linear-gradient(to_top,black_30%,transparent)] px-3 pt-16 pb-2 backdrop-blur-sm transition-transform duration-300 ease-in-out"
|
|
232
|
-
:style="{ transform: overlayVisible ? 'translateY(0)' : 'translateY(100%)' }"
|
|
233
|
-
>
|
|
234
|
-
<div
|
|
235
|
-
ref="scrubberRef"
|
|
236
|
-
role="slider"
|
|
237
|
-
:aria-label="labels.seek"
|
|
238
|
-
:aria-valuemin="0"
|
|
239
|
-
:aria-valuemax="duration || 0"
|
|
240
|
-
:aria-valuenow="Math.round(displayTime)"
|
|
241
|
-
:aria-valuetext="formatTime(displayTime)"
|
|
242
|
-
tabindex="0"
|
|
243
|
-
class="relative h-1.5 cursor-pointer rounded-full bg-white/20"
|
|
244
|
-
@mousedown="startSeeking"
|
|
245
|
-
@touchstart.prevent="startSeeking"
|
|
246
|
-
@keydown="onScrubberKeydown"
|
|
247
|
-
@touchmove.prevent="
|
|
248
|
-
(e) => {
|
|
249
|
-
if (dragRatio !== null) dragRatio = getRatio(e)
|
|
250
|
-
}
|
|
251
|
-
"
|
|
252
|
-
@touchend="
|
|
253
|
-
() => {
|
|
254
|
-
if (dragRatio !== null) commitSeek()
|
|
255
|
-
}
|
|
256
|
-
"
|
|
257
|
-
>
|
|
258
|
-
<div
|
|
259
|
-
class="absolute inset-y-0 left-0 rounded-full bg-white/20"
|
|
260
|
-
:style="{ width: `${bufferedPercent}%` }"
|
|
261
|
-
/>
|
|
262
|
-
<div
|
|
263
|
-
class="absolute inset-y-0 left-0 rounded-full bg-cornflower-blue-500"
|
|
264
|
-
:style="{ width: `${progressPct}%` }"
|
|
265
|
-
/>
|
|
266
|
-
<div
|
|
267
|
-
class="absolute top-1/2 size-3 -translate-1/2 rounded-full bg-sapphire-100 opacity-0 shadow-sm transition-opacity group-hover/player:opacity-100"
|
|
268
|
-
:style="{ left: `${progressPct}%` }"
|
|
269
|
-
/>
|
|
270
|
-
</div>
|
|
271
|
-
|
|
272
|
-
<div class="flex items-center gap-2">
|
|
273
309
|
<button
|
|
274
310
|
type="button"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
311
|
+
ref="startButton"
|
|
312
|
+
data-video-start
|
|
313
|
+
:aria-label="startLabel"
|
|
314
|
+
class="pointer-events-auto flex size-16 cursor-pointer items-center justify-center rounded-full bg-black/50 heading-2 text-white ring-1 ring-white/30 backdrop-blur-xs transition-transform duration-150 ease-out hover:scale-105 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 motion-reduce:transition-none motion-reduce:hover:scale-100 @max-[15rem]:size-12 @max-[15rem]:heading-4"
|
|
315
|
+
@click="onStart"
|
|
278
316
|
>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
<span class="extra-small-normal text-white/70 tabular-nums">
|
|
283
|
-
{{ formatTime(displayTime) }} / {{ formatTime(duration) }}
|
|
284
|
-
</span>
|
|
285
|
-
|
|
286
|
-
<div class="flex-1" />
|
|
287
|
-
|
|
288
|
-
<button
|
|
289
|
-
type="button"
|
|
290
|
-
:aria-label="muted ? labels.unmute : labels.mute"
|
|
291
|
-
class="flex size-8 cursor-pointer items-center justify-center rounded-full text-white transition hover:bg-white/10 hover:text-cornflower-blue-400"
|
|
292
|
-
@click="toggleMute"
|
|
293
|
-
>
|
|
294
|
-
<NexxtIcon
|
|
295
|
-
:name="muted ? 'volume-xmark' : 'volume-high'"
|
|
296
|
-
class="text-sm"
|
|
297
|
-
aria-hidden="true"
|
|
298
|
-
/>
|
|
299
|
-
</button>
|
|
300
|
-
|
|
301
|
-
<button
|
|
302
|
-
type="button"
|
|
303
|
-
:aria-label="isFullscreen ? labels.exitFullscreen : labels.enterFullscreen"
|
|
304
|
-
class="flex size-8 cursor-pointer items-center justify-center rounded-full text-white transition hover:bg-white/10 hover:text-cornflower-blue-400"
|
|
305
|
-
@click="toggleFullscreen"
|
|
306
|
-
>
|
|
307
|
-
<NexxtIcon
|
|
308
|
-
:name="isFullscreen ? 'compress' : 'expand'"
|
|
309
|
-
class="text-sm"
|
|
310
|
-
aria-hidden="true"
|
|
311
|
-
/>
|
|
317
|
+
<!-- The triangle's weight sits left of its box: nudged right to look centred -->
|
|
318
|
+
<NexxtIcon name="play" type="solid" class="translate-x-[0.0625em]" />
|
|
312
319
|
</button>
|
|
313
320
|
</div>
|
|
314
|
-
</
|
|
321
|
+
</Transition>
|
|
315
322
|
</div>
|
|
316
323
|
</template>
|
|
324
|
+
|
|
325
|
+
<style scoped>
|
|
326
|
+
@reference '../../../index.css';
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
* The corner radius is a default, not a rule: it sits in the components layer, below Tailwind's
|
|
330
|
+
* utilities, so a `rounded-*` class on the player wins without `!`. The root is matched by its
|
|
331
|
+
* data attribute, as a class name trips the Tailwind lint.
|
|
332
|
+
*/
|
|
333
|
+
@layer components {
|
|
334
|
+
[data-video-player] {
|
|
335
|
+
@apply rounded-lg;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
* Full screen takes the root, so the controls come along: square corners, black bars and the
|
|
341
|
+
* whole picture. Unlayered on purpose, so it beats a consumer's sizing classes while it lasts.
|
|
342
|
+
*/
|
|
343
|
+
[data-video-player]:fullscreen {
|
|
344
|
+
@apply rounded-none bg-black;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
[data-video-player]:fullscreen > video {
|
|
348
|
+
@apply h-full object-contain;
|
|
349
|
+
}
|
|
350
|
+
</style>
|
package/src/components.json
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"NexxtTimelinePhaseblock": "components/organisms/TimelinePhaseblock/TimelinePhaseblock.vue",
|
|
73
73
|
"NexxtTimelineTrack": "components/organisms/TimelineTrack/TimelineTrack.vue",
|
|
74
74
|
"NexxtToaster": "components/organisms/Toaster/Toaster.vue",
|
|
75
|
+
"NexxtVideoControls": "components/organisms/VideoControls/VideoControls.vue",
|
|
75
76
|
"NexxtVideoPlayer": "components/organisms/VideoPlayer/VideoPlayer.vue",
|
|
76
77
|
"NexxtEditorDimensionField": "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
|
|
77
78
|
"NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,16 @@ export type { EnergyLabelLetter } from './components/atoms/EnergyLabel/EnergyLab
|
|
|
14
14
|
export type { IconType, NexxtIconName } from './components/atoms/Icon/Icon.vue'
|
|
15
15
|
export type { CustomIconName } from './components/atoms/Icon/customIcons'
|
|
16
16
|
export type { NexxtTabItem } from './components/molecules/Tabs/Tabs.vue'
|
|
17
|
+
export type {
|
|
18
|
+
NexxtVideoControlsMessages,
|
|
19
|
+
NexxtVideoControlsProps,
|
|
20
|
+
NexxtVideoControlsVariant,
|
|
21
|
+
} from './components/organisms/VideoControls/VideoControls.vue'
|
|
22
|
+
export type {
|
|
23
|
+
NexxtVideoPlayerControls,
|
|
24
|
+
NexxtVideoPlayerMessages,
|
|
25
|
+
NexxtVideoPlayerProps,
|
|
26
|
+
} from './components/organisms/VideoPlayer/VideoPlayer.vue'
|
|
17
27
|
export type { NexxtComboboxGroup } from './components/organisms/ComboboxPanel/combobox-types'
|
|
18
28
|
export type {
|
|
19
29
|
NexxtPageCardProps,
|
|
@@ -301,4 +311,5 @@ export { default as NexxtToaster } from './components/organisms/Toaster/Toaster.
|
|
|
301
311
|
export { default as NexxtToggleGroup } from './components/molecules/ToggleGroup/ToggleGroup.vue'
|
|
302
312
|
export { default as NexxtTooltip } from './components/atoms/Tooltip/Tooltip.vue'
|
|
303
313
|
export { default as NexxtTree } from './components/molecules/Tree/Tree.vue'
|
|
314
|
+
export { default as NexxtVideoControls } from './components/organisms/VideoControls/VideoControls.vue'
|
|
304
315
|
export { default as NexxtVideoPlayer } from './components/organisms/VideoPlayer/VideoPlayer.vue'
|