@nexxtmove/ui 1.54.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,961 @@
1
+ <script lang="ts">
2
+ import type { NexxtIconName } from '../../atoms/Icon/Icon.vue'
3
+
4
+ /**
5
+ * `full`: the bar; `toggle`: a round play/pause button in the corner, in the same style, with a
6
+ * mute button to its left on hover, focus or a tap.
7
+ */
8
+ export type NexxtVideoControlsVariant = 'full' | 'toggle'
9
+
10
+ /** Overridable copy. Every key is optional; the Dutch defaults are merged per key. */
11
+ export interface NexxtVideoControlsMessages {
12
+ /** Name of the bar; gets the video's name. Defaults to `'Videobediening: {name}'`, or `'Videobediening'` without one. */
13
+ group?: (name: string) => string
14
+ /** Defaults to `'Afspelen'`. */
15
+ play?: string
16
+ /** Defaults to `'Pauzeren'`. */
17
+ pause?: string
18
+ /** The play button after the end. Defaults to `'Opnieuw afspelen'`. */
19
+ replay?: string
20
+ /** Name of the timeline slider. Defaults to `'Tijdlijn'`. */
21
+ timeline?: string
22
+ /** Spoken value of the timeline. Defaults to `'{current} van {duration}'`, e.g. `'0:12 van 0:42'`. */
23
+ timeValue?: (current: string, duration: string) => string
24
+ /** Spoken value of the timeline before the duration is known. Defaults to `'Duur nog onbekend'`. */
25
+ durationUnknown?: string
26
+ /** Defaults to `'Geluid uit'`. */
27
+ mute?: string
28
+ /** Defaults to `'Geluid aan'`. */
29
+ unmute?: string
30
+ /** Name of the volume slider. Defaults to `'Volume'`. */
31
+ volume?: string
32
+ /** Spoken value of the volume slider. Defaults to `'{percent} procent'`. */
33
+ volumeValue?: (percent: number) => string
34
+ /** Spoken value of the volume slider while muted. Defaults to `'Gedempt'`. */
35
+ muted?: string
36
+ /** Defaults to `'Volledig scherm'`. */
37
+ enterFullscreen?: string
38
+ /** Defaults to `'Volledig scherm sluiten'`. */
39
+ exitFullscreen?: string
40
+ }
41
+
42
+ export interface NexxtVideoControlsProps {
43
+ /**
44
+ * The video to control. Its state is read from its media events and written to it directly,
45
+ * so a parent listening to the same video always agrees. The controls position themselves
46
+ * absolutely: the video's parent needs `position: relative`.
47
+ */
48
+ target: HTMLVideoElement | null
49
+ /**
50
+ * What goes full screen: the video's root, so these controls come along. Without it there is
51
+ * no full-screen button. Where the root cannot go full screen (iPhone Safari) the video's own
52
+ * native player opens instead.
53
+ */
54
+ fullscreenTarget?: HTMLElement | null
55
+ /**
56
+ * `full`: the bar; `toggle`: a 32px play/pause button in the corner, with mute to its left on
57
+ * hover, focus or a tap. Both hide while playing by the same rules. Defaults to `full`.
58
+ */
59
+ variant?: NexxtVideoControlsVariant
60
+ /** The video's name, for the name of the bar (or of the toggle's buttons). */
61
+ name?: string
62
+ /** Hide the bar (or the toggle) after inactivity while the video plays. Off keeps it on show. Defaults to `true`. */
63
+ autoHide?: boolean
64
+ /** Inactivity before the bar hides, in milliseconds. Defaults to `2500`. */
65
+ hideDelay?: number
66
+ /** Copy of the buttons and sliders. Defaults are Dutch and are merged per key. */
67
+ messages?: NexxtVideoControlsMessages
68
+ }
69
+
70
+ type PlayIcon = Extract<NexxtIconName, 'play' | 'pause' | 'rotate-right'>
71
+ </script>
72
+
73
+ <script setup lang="ts">
74
+ import { useElementSize, useEventListener, useMediaQuery } from '@vueuse/core'
75
+ import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
76
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
77
+
78
+ defineOptions({ name: 'NexxtVideoControls' })
79
+
80
+ const {
81
+ target,
82
+ fullscreenTarget = null,
83
+ variant = 'full',
84
+ name = '',
85
+ autoHide = true,
86
+ hideDelay = 2500,
87
+ messages,
88
+ } = defineProps<NexxtVideoControlsProps>()
89
+
90
+ const emit = defineEmits<{
91
+ /** The bar was shown or hidden; the parent can hide the cursor with it. */
92
+ 'visibility-change': [visible: boolean]
93
+ /** The full-screen target entered or left full screen. */
94
+ 'fullscreen-change': [fullscreen: boolean]
95
+ }>()
96
+
97
+ const DEFAULT_MESSAGES = {
98
+ group: (value: string) => (value ? `Videobediening: ${value}` : 'Videobediening'),
99
+ play: 'Afspelen',
100
+ pause: 'Pauzeren',
101
+ replay: 'Opnieuw afspelen',
102
+ timeline: 'Tijdlijn',
103
+ timeValue: (current: string, duration: string) => `${current} van ${duration}`,
104
+ durationUnknown: 'Duur nog onbekend',
105
+ mute: 'Geluid uit',
106
+ unmute: 'Geluid aan',
107
+ volume: 'Volume',
108
+ volumeValue: (percent: number) => `${percent} procent`,
109
+ muted: 'Gedempt',
110
+ enterFullscreen: 'Volledig scherm',
111
+ exitFullscreen: 'Volledig scherm sluiten',
112
+ } satisfies Required<NexxtVideoControlsMessages>
113
+
114
+ /** How long the bar stays after the pointer leaves the video. */
115
+ const LEAVE_DELAY = 300
116
+ /** Arrow keys on the timeline, in seconds. */
117
+ const SEEK_STEP = 5
118
+ /** Page Up / Page Down on the timeline, as a share of the duration. */
119
+ const SEEK_PAGE = 0.1
120
+ /** Arrow keys on the volume. */
121
+ const VOLUME_STEP = 0.1
122
+ /** How long the volume popover stays after the pointer or the focus leaves it. */
123
+ const VOLUME_CLOSE_DELAY = 300
124
+ /**
125
+ * Player height the volume popover needs: the bar (40px, 12px from the bottom), the 8px bridge,
126
+ * the 96px popover and 8px to spare above it, so the player's corners never clip it.
127
+ */
128
+ const VOLUME_ROOM = 164
129
+
130
+ type WebkitVideo = HTMLVideoElement & { webkitEnterFullscreen?: () => void }
131
+
132
+ const playButton = useTemplateRef<HTMLButtonElement>('playButton')
133
+ const capsule = useTemplateRef<HTMLElement>('capsule')
134
+ const track = useTemplateRef<HTMLElement>('track')
135
+ const volumeTrack = useTemplateRef<HTMLElement>('volumeTrack')
136
+ const soundButton = useTemplateRef<HTMLButtonElement>('soundButton')
137
+
138
+ const paused = ref(true)
139
+ const ended = ref(false)
140
+ const currentTime = ref(0)
141
+ const duration = ref(0)
142
+ const bufferedEnd = ref(0)
143
+ const volume = ref(1)
144
+ const muted = ref(false)
145
+ /** The time the timeline is dragged to; the video only seeks when it is let go. */
146
+ const scrubTime = ref<number | null>(null)
147
+ const draggingVolume = ref(false)
148
+ /** Pointer over the sound button or its popover (the bridge between them included). */
149
+ const overSound = ref(false)
150
+ /** Keyboard focus on the sound button or the volume slider (hover opens it too). */
151
+ const focusInSound = ref(false)
152
+ /** Escape closed the popover; focus back on the sound button must not reopen it. */
153
+ const volumeDismissed = ref(false)
154
+ /** The popover is on show; it closes `VOLUME_CLOSE_DELAY` after it stops being wanted. */
155
+ const volumeOpen = ref(false)
156
+ const pointerActive = ref(false)
157
+ const overCapsule = ref(false)
158
+ /**
159
+ * Keyboard focus in the bar. Only keyboard focus keeps it on show: a button clicked with the
160
+ * mouse keeps the focus, and the bar would otherwise never hide again.
161
+ */
162
+ const focusInside = ref(false)
163
+ const touchShown = ref(false)
164
+ /** The pointer is over the video (toggle: brings the mute button). */
165
+ const overVideo = ref(false)
166
+ const fullscreen = ref(false)
167
+ const mounted = ref(false)
168
+
169
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
170
+
171
+ const durationKnown = computed(() => Number.isFinite(duration.value) && duration.value > 0)
172
+
173
+ const shownTime = computed(() => scrubTime.value ?? currentTime.value)
174
+
175
+ const progress = computed(() =>
176
+ durationKnown.value ? Math.min(1, shownTime.value / duration.value) : 0,
177
+ )
178
+
179
+ const buffer = computed(() =>
180
+ durationKnown.value ? Math.min(1, bufferedEnd.value / duration.value) : 0,
181
+ )
182
+
183
+ const effectiveVolume = computed(() => (muted.value ? 0 : volume.value))
184
+
185
+ const playLabel = computed(() => {
186
+ if (!paused.value) return copy.value.pause
187
+ return ended.value ? copy.value.replay : copy.value.play
188
+ })
189
+
190
+ const playIcon = computed<PlayIcon>(() => {
191
+ if (!paused.value) return 'pause'
192
+ return ended.value ? 'rotate-right' : 'play'
193
+ })
194
+
195
+ // The toggle stands on its own, so it carries the name the group would otherwise give it
196
+ const toggleLabel = computed(() => (name ? `${playLabel.value}: ${name}` : playLabel.value))
197
+
198
+ const soundLabel = computed(() =>
199
+ effectiveVolume.value === 0 ? copy.value.unmute : copy.value.mute,
200
+ )
201
+
202
+ const soundIcon = computed<NexxtIconName>(() => {
203
+ if (effectiveVolume.value === 0) return 'volume-xmark'
204
+ return effectiveVolume.value < 0.5 ? 'volume-low' : 'volume-high'
205
+ })
206
+
207
+ // Asks the document, so client-only: the button appears after mount, never in server HTML
208
+ const fullscreenSupported = computed(() => {
209
+ if (!mounted.value || !fullscreenTarget) return false
210
+ return (
211
+ document.fullscreenEnabled ||
212
+ typeof (target as WebkitVideo | null)?.webkitEnterFullscreen === 'function'
213
+ )
214
+ })
215
+
216
+ const visible = computed(
217
+ () =>
218
+ !autoHide ||
219
+ paused.value ||
220
+ focusInside.value ||
221
+ overCapsule.value ||
222
+ pointerActive.value ||
223
+ touchShown.value ||
224
+ scrubTime.value !== null ||
225
+ draggingVolume.value ||
226
+ volumeOpen.value,
227
+ )
228
+
229
+ /**
230
+ * Where a knob sits along its track: kept inside it, so at 0 and at the end the 12px knob does
231
+ * not stick out over the time or the next button. Its centre runs from 6px to the width - 6px.
232
+ */
233
+ const knobLeft = (position: number) => `calc(${position * 100}% + ${6 - position * 12}px)`
234
+
235
+ /** The same, from the bottom of a vertical track. */
236
+ const knobBottom = (position: number) => `calc(${position * 100}% + ${6 - position * 12}px)`
237
+
238
+ /** Fade classes of the bar and of the toggle: opacity only, in quick, out slow. */
239
+ const fadeClasses = computed(() =>
240
+ visible.value
241
+ ? 'pointer-events-auto opacity-100 duration-150 ease-out'
242
+ : 'pointer-events-none opacity-0 duration-300 ease-in',
243
+ )
244
+
245
+ // Toggle: the mute button only comes with a pointer over the video, focus, or a tap
246
+ const toggleMuteShown = computed(() => overVideo.value || focusInside.value || touchShown.value)
247
+
248
+ const toggleMuteClasses = computed(() =>
249
+ toggleMuteShown.value
250
+ ? 'opacity-100 duration-150 ease-out'
251
+ : 'pointer-events-none opacity-0 duration-300 ease-in',
252
+ )
253
+
254
+ const toggleMuteLabel = computed(() => (name ? `${soundLabel.value}: ${name}` : soundLabel.value))
255
+
256
+ const capsuleClasses = computed(() => [
257
+ fullscreen.value ? 'mx-6 mb-6' : 'mx-3 mb-3 @max-xs:mx-2 @max-xs:mb-2',
258
+ // Opacity only, no movement: in quick, out slow, and instant with reduced motion
259
+ visible.value
260
+ ? 'pointer-events-auto opacity-100 duration-150 ease-out'
261
+ : 'opacity-0 duration-300 ease-in',
262
+ ])
263
+
264
+ /** `m:ss`, from an hour `h:mm:ss`. */
265
+ const formatTime = (seconds: number) => {
266
+ const whole = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0
267
+ const hours = Math.floor(whole / 3600)
268
+ const minutes = Math.floor((whole % 3600) / 60)
269
+ const rest = String(whole % 60).padStart(2, '0')
270
+
271
+ return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`
272
+ }
273
+
274
+ const timeText = computed(
275
+ () => `${formatTime(shownTime.value)} / ${formatTime(durationKnown.value ? duration.value : 0)}`,
276
+ )
277
+
278
+ // Whole seconds: Vue only patches the attribute when the second changes, so a screen reader
279
+ // does not read every `timeupdate`
280
+ const timelineNow = computed(() => Math.floor(shownTime.value))
281
+ const timelineMax = computed(() => (durationKnown.value ? Math.floor(duration.value) : 0))
282
+
283
+ const timelineValueText = computed(() =>
284
+ durationKnown.value
285
+ ? copy.value.timeValue(formatTime(shownTime.value), formatTime(duration.value))
286
+ : copy.value.durationUnknown,
287
+ )
288
+
289
+ const volumeNow = computed(() => Math.round(effectiveVolume.value * 100))
290
+
291
+ const volumeValueText = computed(() =>
292
+ muted.value ? copy.value.muted : copy.value.volumeValue(Math.round(volume.value * 100)),
293
+ )
294
+
295
+ const readState = () => {
296
+ const video = target
297
+ if (!video) return
298
+
299
+ paused.value = video.paused
300
+ ended.value = video.ended
301
+ currentTime.value = video.currentTime
302
+ duration.value = video.duration
303
+ volume.value = video.volume
304
+ muted.value = video.muted
305
+
306
+ // The end of the buffered range the playhead is in
307
+ const ranges = video.buffered
308
+ let end = 0
309
+ for (let index = 0; index < ranges.length; index++) {
310
+ if (ranges.start(index) <= video.currentTime + 0.5) end = Math.max(end, ranges.end(index))
311
+ }
312
+ bufferedEnd.value = end
313
+ }
314
+
315
+ let hideTimer: ReturnType<typeof setTimeout> | undefined
316
+ let touchTimer: ReturnType<typeof setTimeout> | undefined
317
+
318
+ /** Show the bar until the pointer has been still for `hideDelay`. */
319
+ const show = () => {
320
+ pointerActive.value = true
321
+ clearTimeout(hideTimer)
322
+ hideTimer = setTimeout(() => {
323
+ pointerActive.value = false
324
+ }, hideDelay)
325
+ }
326
+
327
+ const leave = () => {
328
+ clearTimeout(hideTimer)
329
+ hideTimer = setTimeout(() => {
330
+ pointerActive.value = false
331
+ }, LEAVE_DELAY)
332
+ }
333
+
334
+ const togglePlay = () => {
335
+ const video = target
336
+ if (!video) return
337
+
338
+ if (!video.paused) {
339
+ video.pause()
340
+ return
341
+ }
342
+
343
+ if (video.ended) video.currentTime = 0
344
+ video.play().catch(() => {
345
+ // Refused or interrupted: the video's own events say what it does
346
+ })
347
+ }
348
+
349
+ const toggleMute = () => {
350
+ const video = target
351
+ if (!video) return
352
+
353
+ video.muted = !video.muted
354
+ // Back on at nothing would still be silent
355
+ if (!video.muted && video.volume === 0) video.volume = 0.5
356
+ }
357
+
358
+ const setVolume = (value: number) => {
359
+ const video = target
360
+ if (!video) return
361
+
362
+ const next = Math.min(1, Math.max(0, value))
363
+ video.volume = next
364
+ video.muted = next === 0
365
+ }
366
+
367
+ const seek = (time: number) => {
368
+ const video = target
369
+ if (!video || !durationKnown.value) return
370
+
371
+ video.currentTime = Math.min(duration.value, Math.max(0, time))
372
+ currentTime.value = video.currentTime
373
+ }
374
+
375
+ /**
376
+ * iOS: the native player. It throws (InvalidStateError) before the metadata is in, as with a
377
+ * poster and `preload="none"`; then the metadata is loaded and it is tried once more. A refusal
378
+ * the second time (the user gesture may have expired by then) is swallowed: nothing happens.
379
+ */
380
+ const enterNativeFullscreen = async (video: WebkitVideo) => {
381
+ try {
382
+ video.webkitEnterFullscreen?.()
383
+ return
384
+ } catch {
385
+ if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return
386
+ }
387
+
388
+ await new Promise<void>((resolve) => {
389
+ video.addEventListener('loadedmetadata', () => resolve(), { once: true })
390
+ video.addEventListener('error', () => resolve(), { once: true })
391
+ video.load()
392
+ })
393
+
394
+ try {
395
+ video.webkitEnterFullscreen?.()
396
+ } catch {
397
+ // Still refused: the button simply does nothing
398
+ }
399
+ }
400
+
401
+ const toggleFullscreen = async () => {
402
+ if (document.fullscreenElement) {
403
+ await document.exitFullscreen().catch(() => {})
404
+ return
405
+ }
406
+
407
+ if (fullscreenTarget && document.fullscreenEnabled) {
408
+ await fullscreenTarget.requestFullscreen().catch(() => {})
409
+ } else if (target) {
410
+ await enterNativeFullscreen(target as WebkitVideo)
411
+ }
412
+ }
413
+
414
+ /** Where along a track the pointer is, 0 to 1. */
415
+ const share = (element: HTMLElement | null, event: PointerEvent) => {
416
+ if (!element) return 0
417
+ const rect = element.getBoundingClientRect()
418
+ return rect.width ? Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) : 0
419
+ }
420
+
421
+ const onTrackDown = (event: PointerEvent) => {
422
+ if (!durationKnown.value) return
423
+ track.value?.setPointerCapture?.(event.pointerId)
424
+ scrubTime.value = share(track.value, event) * duration.value
425
+ }
426
+
427
+ const onTrackMove = (event: PointerEvent) => {
428
+ if (scrubTime.value === null) return
429
+ scrubTime.value = share(track.value, event) * duration.value
430
+ }
431
+
432
+ /** One seek, on letting go: a seek per pointer move would empty the buffer again and again. */
433
+ const onTrackUp = (event: PointerEvent) => {
434
+ if (scrubTime.value === null) return
435
+ track.value?.releasePointerCapture?.(event.pointerId)
436
+ seek(scrubTime.value)
437
+ scrubTime.value = null
438
+ show()
439
+ }
440
+
441
+ /** A cancelled pointer (a scroll took over, the system stepped in) seeks nowhere. */
442
+ const onTrackCancel = (event: PointerEvent) => {
443
+ if (scrubTime.value === null) return
444
+ track.value?.releasePointerCapture?.(event.pointerId)
445
+ scrubTime.value = null
446
+ show()
447
+ }
448
+
449
+ /** Where along a vertical track the pointer is, 0 at the bottom to 1 at the top. */
450
+ const shareUp = (element: HTMLElement | null, event: PointerEvent) => {
451
+ if (!element) return 0
452
+ const rect = element.getBoundingClientRect()
453
+ return rect.height ? Math.min(1, Math.max(0, (rect.bottom - event.clientY) / rect.height)) : 0
454
+ }
455
+
456
+ const onVolumeDown = (event: PointerEvent) => {
457
+ volumeTrack.value?.setPointerCapture?.(event.pointerId)
458
+ draggingVolume.value = true
459
+ setVolume(shareUp(volumeTrack.value, event))
460
+ }
461
+
462
+ const onVolumeMove = (event: PointerEvent) => {
463
+ if (draggingVolume.value) setVolume(shareUp(volumeTrack.value, event))
464
+ }
465
+
466
+ const onVolumeUp = (event: PointerEvent) => {
467
+ if (!draggingVolume.value) return
468
+ volumeTrack.value?.releasePointerCapture?.(event.pointerId)
469
+ draggingVolume.value = false
470
+ show()
471
+ }
472
+
473
+ const onTrackKeydown = (event: KeyboardEvent) => {
474
+ if (!durationKnown.value) return
475
+
476
+ const steps: Record<string, number> = {
477
+ ArrowLeft: -SEEK_STEP,
478
+ ArrowDown: -SEEK_STEP,
479
+ ArrowRight: SEEK_STEP,
480
+ ArrowUp: SEEK_STEP,
481
+ PageDown: -duration.value * SEEK_PAGE,
482
+ PageUp: duration.value * SEEK_PAGE,
483
+ }
484
+
485
+ if (event.key === 'Home') seek(0)
486
+ else if (event.key === 'End') seek(duration.value)
487
+ else if (event.key in steps) seek(currentTime.value + (steps[event.key] ?? 0))
488
+ else return
489
+
490
+ event.preventDefault()
491
+ }
492
+
493
+ let volumeTimer: ReturnType<typeof setTimeout> | undefined
494
+
495
+ const closeVolume = () => {
496
+ clearTimeout(volumeTimer)
497
+ volumeOpen.value = false
498
+ }
499
+
500
+ const onVolumeKeydown = (event: KeyboardEvent) => {
501
+ const steps: Record<string, number> = {
502
+ ArrowLeft: -VOLUME_STEP,
503
+ ArrowDown: -VOLUME_STEP,
504
+ ArrowRight: VOLUME_STEP,
505
+ ArrowUp: VOLUME_STEP,
506
+ }
507
+
508
+ if (event.key === 'Home') setVolume(0)
509
+ else if (event.key === 'End') setVolume(1)
510
+ else if (event.key in steps) setVolume(effectiveVolume.value + (steps[event.key] ?? 0))
511
+ else return
512
+
513
+ event.preventDefault()
514
+ }
515
+
516
+ /** Space, M and F, only with the focus in the bar: the page has keys of its own. */
517
+ const onShortcut = (event: KeyboardEvent) => {
518
+ if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) return
519
+
520
+ // On a button, Space is that button's own activation
521
+ const onButton = event.target instanceof HTMLButtonElement
522
+ const key = event.key.toLowerCase()
523
+
524
+ if (key === ' ' && !onButton) togglePlay()
525
+ else if (key === 'm') toggleMute()
526
+ else if (key === 'f' && fullscreenSupported.value) void toggleFullscreen()
527
+ else return
528
+
529
+ event.preventDefault()
530
+ }
531
+
532
+ /** A pointer press is under way: the focus it brings is not keyboard focus. */
533
+ let pointerFocus = false
534
+ let pointerFocusTimer: ReturnType<typeof setTimeout> | undefined
535
+
536
+ /** Whether an element shows keyboard focus; where `:focus-visible` is unknown, assume it does. */
537
+ const isFocusVisible = (element: EventTarget | null) => {
538
+ try {
539
+ return element instanceof Element && element.matches(':focus-visible')
540
+ } catch {
541
+ return true
542
+ }
543
+ }
544
+
545
+ const onFocusIn = (event: FocusEvent) => {
546
+ focusInside.value = !pointerFocus && isFocusVisible(event.target)
547
+ }
548
+
549
+ const onFocusOut = (event: FocusEvent) => {
550
+ focusInside.value =
551
+ focusInside.value && (capsule.value?.contains(event.relatedTarget as Node | null) ?? false)
552
+ }
553
+
554
+ /**
555
+ * A press inside the bar is the bar's: no drag or selection in whatever holds the video. It
556
+ * also marks the focus it brings as pointer focus, until that focus has landed.
557
+ */
558
+ const onPressInside = (event: PointerEvent) => {
559
+ event.stopPropagation()
560
+ pointerFocus = true
561
+ focusInside.value = false
562
+ clearTimeout(pointerFocusTimer)
563
+ pointerFocusTimer = setTimeout(() => (pointerFocus = false))
564
+ }
565
+
566
+ /** A key in the bar: whoever uses the keyboard sees where they are, from now on. */
567
+ const onKeyInside = (event: KeyboardEvent) => {
568
+ focusInside.value = true
569
+ if (variant === 'full') onShortcut(event)
570
+ }
571
+
572
+ /** Mouse: a click on the video plays and pauses it. Touch: a tap shows or hides the bar, never pauses. */
573
+ let lastPointerType = 'mouse'
574
+
575
+ const onVideoPointerDown = (event: PointerEvent) => {
576
+ lastPointerType = event.pointerType
577
+ }
578
+
579
+ const onVideoClick = () => {
580
+ // A mouse click plays and pauses with the bar only; the toggle leaves clicks to the parent
581
+ if (lastPointerType !== 'touch') {
582
+ if (variant === 'full') togglePlay()
583
+ return
584
+ }
585
+
586
+ // A tap toggles what touch cannot hover for: the bar, or the toggle's mute button
587
+ clearTimeout(touchTimer)
588
+ touchShown.value = !(variant === 'toggle' ? toggleMuteShown.value : visible.value)
589
+ if (touchShown.value) {
590
+ touchTimer = setTimeout(() => {
591
+ touchShown.value = false
592
+ }, hideDelay)
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Focus the play/pause button, e.g. after a big play button that started the video. Pass
598
+ * `{ focusVisible: false }` after a mouse click (the bar then hides as usual), and
599
+ * `{ focusVisible: true }` after a key (the bar stays, as with any keyboard focus).
600
+ */
601
+ const focus = (options?: FocusOptions & { focusVisible?: boolean }) => {
602
+ pointerFocus = options?.focusVisible === false
603
+ playButton.value?.focus(options)
604
+ pointerFocus = false
605
+ // Asked for as keyboard focus: the bar stays, whatever the browser's own heuristic says, but
606
+ // only if the focus really landed (an inert or missing button takes none)
607
+ if (options?.focusVisible === true) {
608
+ focusInside.value = capsule.value?.contains(document.activeElement) ?? false
609
+ }
610
+ }
611
+
612
+ defineExpose({ focus, show })
613
+
614
+ const MEDIA_EVENTS = [
615
+ 'play',
616
+ 'pause',
617
+ 'ended',
618
+ 'timeupdate',
619
+ 'durationchange',
620
+ 'loadedmetadata',
621
+ 'progress',
622
+ 'volumechange',
623
+ 'seeked',
624
+ 'emptied',
625
+ ]
626
+ useEventListener(() => target, MEDIA_EVENTS, readState)
627
+ useEventListener(() => target, 'pointerdown', onVideoPointerDown)
628
+ useEventListener(() => target, 'click', onVideoClick)
629
+
630
+ const hoverArea = () => fullscreenTarget ?? target?.parentElement ?? null
631
+ useEventListener(hoverArea, 'pointermove', (event: PointerEvent) => {
632
+ if (event.pointerType !== 'touch') show()
633
+ })
634
+ useEventListener(hoverArea, 'pointerleave', () => {
635
+ overVideo.value = false
636
+ leave()
637
+ })
638
+ useEventListener(hoverArea, 'pointerenter', (event: PointerEvent) => {
639
+ if (event.pointerType !== 'touch') overVideo.value = true
640
+ })
641
+
642
+ // iOS ignores `video.volume`, and a fingertip has no hover: on touch the sound button is all
643
+ const coarsePointer = useMediaQuery('(pointer: coarse)')
644
+ const { height: playerHeight } = useElementSize(hoverArea, undefined, { box: 'border-box' })
645
+
646
+ // 0 means not measured (no ResizeObserver: on the server, in jsdom): keep the slider then
647
+ const volumeAvailable = computed(
648
+ () => !coarsePointer.value && (playerHeight.value === 0 || playerHeight.value >= VOLUME_ROOM),
649
+ )
650
+
651
+ const volumeWanted = computed(
652
+ () => (overSound.value || focusInSound.value || draggingVolume.value) && !volumeDismissed.value,
653
+ )
654
+
655
+ watch(volumeWanted, (wanted) => {
656
+ clearTimeout(volumeTimer)
657
+ if (wanted) volumeOpen.value = true
658
+ else volumeTimer = setTimeout(() => (volumeOpen.value = false), VOLUME_CLOSE_DELAY)
659
+ })
660
+
661
+ const volumeClasses = computed(() =>
662
+ volumeOpen.value
663
+ ? 'pointer-events-auto opacity-100 duration-150 ease-out'
664
+ : 'pointer-events-none opacity-0 duration-300 ease-in',
665
+ )
666
+
667
+ /**
668
+ * Escape with the popover open closes the popover and nothing else: not a `<dialog>` around the
669
+ * player (its cancel is the key's default action, hence preventDefault) and not whatever the
670
+ * page does with Escape (hence the capture phase on window, before any other handler, and the
671
+ * stop). Hover or focus, wherever the focus is; from the slider the focus goes back to the
672
+ * sound button, elsewhere it stays. With the popover closed Escape passes untouched.
673
+ */
674
+ const onEscapeCapture = (event: KeyboardEvent) => {
675
+ if (event.key !== 'Escape' || !volumeOpen.value) return
676
+
677
+ event.preventDefault()
678
+ event.stopImmediatePropagation()
679
+ const fromSlider = document.activeElement === volumeTrack.value
680
+ volumeDismissed.value = true
681
+ closeVolume()
682
+ if (fromSlider) soundButton.value?.focus()
683
+ }
684
+
685
+ useEventListener(() => (volumeOpen.value ? window : null), 'keydown', onEscapeCapture, {
686
+ capture: true,
687
+ })
688
+
689
+ // The slider can go while it has the focus (the player shrank below VOLUME_ROOM)
690
+ watch(volumeAvailable, (available) => {
691
+ if (available) return
692
+ focusInSound.value = false
693
+ draggingVolume.value = false
694
+ closeVolume()
695
+ })
696
+
697
+ const onSoundEnter = () => {
698
+ overSound.value = true
699
+ volumeDismissed.value = false
700
+ }
701
+
702
+ const onSoundFocusIn = (event: FocusEvent) => {
703
+ focusInSound.value = !pointerFocus && isFocusVisible(event.target)
704
+ // Coming back to the slider reopens what Escape closed
705
+ if (event.target === volumeTrack.value) volumeDismissed.value = false
706
+ }
707
+
708
+ const onSoundFocusOut = (event: FocusEvent) => {
709
+ const wrapper = event.currentTarget as HTMLElement
710
+ focusInSound.value = focusInSound.value && wrapper.contains(event.relatedTarget as Node | null)
711
+ if (!focusInSound.value) volumeDismissed.value = false
712
+ }
713
+ useEventListener(
714
+ () => (mounted.value ? document : null),
715
+ 'fullscreenchange',
716
+ () => {
717
+ fullscreen.value = Boolean(fullscreenTarget) && document.fullscreenElement === fullscreenTarget
718
+ emit('fullscreen-change', fullscreen.value)
719
+ },
720
+ )
721
+
722
+ watch(() => target, readState)
723
+ watch(visible, (value) => emit('visibility-change', value))
724
+
725
+ onMounted(() => {
726
+ mounted.value = true
727
+ readState()
728
+ // It arrives with the start: on show for a moment, so whoever just started it sees where it went
729
+ show()
730
+ emit('visibility-change', visible.value)
731
+ })
732
+
733
+ onBeforeUnmount(() => {
734
+ clearTimeout(hideTimer)
735
+ clearTimeout(touchTimer)
736
+ clearTimeout(volumeTimer)
737
+ clearTimeout(pointerFocusTimer)
738
+ })
739
+ </script>
740
+
741
+ <template>
742
+ <div
743
+ v-if="variant === 'toggle'"
744
+ class="@container pointer-events-none absolute inset-0 print:hidden"
745
+ >
746
+ <!--
747
+ The corner buttons: play/pause in the corner, and mute to its left on hover, focus or a tap
748
+ (DOM and tab order follow what is seen: mute, then play). Mute is transparent when hidden,
749
+ never removed, so play keeps its place and both stay in the tab order. Both fade with the
750
+ same rules as the bar.
751
+ -->
752
+ <div
753
+ ref="capsule"
754
+ data-video-controls
755
+ class="absolute right-3 bottom-3 flex gap-1 transition-opacity motion-reduce:transition-none @max-xs:right-2 @max-xs:bottom-2"
756
+ :class="fadeClasses"
757
+ @pointerdown="onPressInside"
758
+ @pointerenter="overCapsule = true"
759
+ @pointerleave="overCapsule = false"
760
+ @focusin="onFocusIn"
761
+ @focusout="onFocusOut"
762
+ @keydown="onKeyInside"
763
+ >
764
+ <button
765
+ type="button"
766
+ :aria-label="toggleMuteLabel"
767
+ class="group/mute flex cursor-pointer rounded-full bg-black/50 small-normal text-white ring-1 ring-white/30 backdrop-blur-xs transition-opacity 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"
768
+ :class="toggleMuteClasses"
769
+ @click="toggleMute"
770
+ >
771
+ <span
772
+ class="flex size-8 items-center justify-center rounded-full transition-colors duration-150 ease-out group-hover/mute:bg-white/15 motion-reduce:transition-none"
773
+ >
774
+ <!--
775
+ The volume glyphs differ in width (0.625–1.25em) and Font Awesome centres each in
776
+ its own box, so the speaker would jump as the waves change. Each glyph takes its own
777
+ width, left-aligned in a box as wide as the widest (volume-high): the speaker stays put,
778
+ and the full icon sits centred.
779
+ -->
780
+ <span data-sound-glyph class="flex w-[1.25em] justify-start">
781
+ <NexxtIcon :name="soundIcon" type="solid" class="[--fa-width:auto]" />
782
+ </span>
783
+ </span>
784
+ </button>
785
+ <button
786
+ ref="playButton"
787
+ type="button"
788
+ :aria-label="toggleLabel"
789
+ class="group/toggle flex cursor-pointer rounded-full bg-black/50 small-normal text-white ring-1 ring-white/30 backdrop-blur-xs focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
790
+ @click="togglePlay"
791
+ >
792
+ <span
793
+ class="flex size-8 items-center justify-center rounded-full transition-colors duration-150 ease-out group-hover/toggle:bg-white/15 motion-reduce:transition-none"
794
+ >
795
+ <NexxtIcon :name="playIcon" type="solid" />
796
+ </span>
797
+ </button>
798
+ </div>
799
+ </div>
800
+
801
+ <div v-else class="@container pointer-events-none absolute inset-x-0 bottom-0 print:hidden">
802
+ <!--
803
+ The capsule is a scrim over whatever frame the video shows, so its colours are raw alphas:
804
+ it is no theme surface and there is no semantic token for it. black/50 is the lightest that
805
+ still holds. Worst pixel, measured on the white living room of demo-tour.webm (5.1–8.8 s)
806
+ with the xs blur on (the ScrimContrast story): white time 4.62:1, icons and knob 4.35:1,
807
+ progress against the white/25 track 3.33:1 in the bar and 3.62:1 in the volume popover
808
+ (white/30 would leave the bar's at about 3.0). The white/30 hairline outlines it on dark
809
+ frames. The volume popover and the toggle use the same scrim.
810
+ -->
811
+ <div
812
+ ref="capsule"
813
+ data-video-controls
814
+ role="group"
815
+ :aria-label="copy.group(name)"
816
+ class="relative isolate flex h-10 items-center gap-1 px-1 text-white transition-opacity motion-reduce:transition-none"
817
+ :class="capsuleClasses"
818
+ @pointerdown="onPressInside"
819
+ @pointerenter="overCapsule = true"
820
+ @pointerleave="overCapsule = false"
821
+ @focusin="onFocusIn"
822
+ @focusout="onFocusOut"
823
+ @keydown="onKeyInside"
824
+ >
825
+ <!--
826
+ The scrim is a layer behind the content, not the capsule itself: an element with a
827
+ backdrop-filter is the backdrop root of everything inside it, so the volume popover's own
828
+ blur would only see the capsule. This way it blurs the video.
829
+ -->
830
+ <span
831
+ data-scrim
832
+ aria-hidden="true"
833
+ class="absolute inset-0 -z-10 rounded-full bg-black/50 ring-1 ring-white/30 backdrop-blur-xs"
834
+ />
835
+ <button
836
+ ref="playButton"
837
+ type="button"
838
+ :aria-label="playLabel"
839
+ class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full small-normal transition-[background-color] duration-150 ease-out hover:bg-white/15 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"
840
+ @click="togglePlay"
841
+ >
842
+ <NexxtIcon :name="playIcon" type="solid" />
843
+ </button>
844
+
845
+ <span class="shrink-0 @max-xs:hidden">
846
+ <span aria-hidden="true" class="px-1 extra-small-normal whitespace-nowrap tabular-nums">
847
+ {{ timeText }}
848
+ </span>
849
+ </span>
850
+
851
+ <div
852
+ ref="track"
853
+ role="slider"
854
+ tabindex="0"
855
+ :aria-label="copy.timeline"
856
+ aria-valuemin="0"
857
+ :aria-valuemax="timelineMax"
858
+ :aria-valuenow="timelineNow"
859
+ :aria-valuetext="timelineValueText"
860
+ :aria-disabled="durationKnown ? undefined : 'true'"
861
+ class="group/track relative flex h-8 min-w-12 flex-1 cursor-pointer touch-none items-center outline-hidden"
862
+ @pointerdown="onTrackDown"
863
+ @pointermove="onTrackMove"
864
+ @pointerup="onTrackUp"
865
+ @pointercancel="onTrackCancel"
866
+ @keydown="onTrackKeydown"
867
+ >
868
+ <span class="h-1 w-full rounded-full bg-white/25" />
869
+ <span
870
+ class="absolute h-1 rounded-full bg-white/50"
871
+ :style="{ width: `${buffer * 100}%` }"
872
+ />
873
+ <span class="absolute h-1 rounded-full bg-white" :style="{ width: `${progress * 100}%` }" />
874
+ <span
875
+ class="absolute size-3 -translate-x-1/2 rounded-full bg-white group-hover/track:opacity-100 group-focus-visible/track:opacity-100 group-focus-visible/track:ring-2 group-focus-visible/track:ring-white group-focus-visible/track:outline-2 group-focus-visible/track:outline-offset-2 group-focus-visible/track:outline-blue-600 pointer-coarse:opacity-100"
876
+ :class="scrubTime !== null ? 'opacity-100' : 'opacity-0'"
877
+ :style="{ left: knobLeft(progress) }"
878
+ />
879
+ </div>
880
+
881
+ <!--
882
+ Sound: a click mutes, and a vertical volume slider opens above it on hover or focus.
883
+ The popover's bottom padding bridges the gap to the button, so moving the pointer up
884
+ does not close it. Hidden is transparent, so the slider stays in the tab order and
885
+ opens on focus.
886
+ -->
887
+ <span
888
+ class="relative shrink-0 @max-[10rem]:hidden"
889
+ @pointerenter="onSoundEnter"
890
+ @pointerleave="overSound = false"
891
+ @focusin="onSoundFocusIn"
892
+ @keydown="focusInSound = true"
893
+ @focusout="onSoundFocusOut"
894
+ >
895
+ <button
896
+ ref="soundButton"
897
+ type="button"
898
+ :aria-label="soundLabel"
899
+ class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full small-normal transition-[background-color] duration-150 ease-out hover:bg-white/15 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"
900
+ @click="toggleMute"
901
+ >
902
+ <!-- The same fixed glyph box as the other sound button: the speaker stays put -->
903
+ <span data-sound-glyph class="flex w-[1.25em] justify-start">
904
+ <NexxtIcon :name="soundIcon" type="solid" class="[--fa-width:auto]" />
905
+ </span>
906
+ </button>
907
+
908
+ <span
909
+ v-if="volumeAvailable"
910
+ data-volume-popover
911
+ class="absolute bottom-full left-1/2 -translate-x-1/2 pb-2 transition-opacity motion-reduce:transition-none"
912
+ :class="volumeClasses"
913
+ >
914
+ <!-- py-2: with the track's own 6px inset, 14px from its ends to the edge, as at its sides -->
915
+ <span
916
+ class="flex h-24 w-8 justify-center rounded-full bg-black/50 py-2 ring-1 ring-white/30 backdrop-blur-xs"
917
+ >
918
+ <span
919
+ ref="volumeTrack"
920
+ role="slider"
921
+ tabindex="0"
922
+ aria-orientation="vertical"
923
+ :aria-label="copy.volume"
924
+ aria-valuemin="0"
925
+ aria-valuemax="100"
926
+ :aria-valuenow="volumeNow"
927
+ :aria-valuetext="volumeValueText"
928
+ class="group/volume relative flex h-full w-8 cursor-pointer touch-none justify-center outline-hidden"
929
+ @pointerdown="onVolumeDown"
930
+ @pointermove="onVolumeMove"
931
+ @pointerup="onVolumeUp"
932
+ @pointercancel="onVolumeUp"
933
+ @keydown="onVolumeKeydown"
934
+ >
935
+ <span class="my-1.5 w-1 rounded-full bg-white/25" />
936
+ <span
937
+ class="absolute bottom-1.5 w-1 rounded-full bg-white"
938
+ :style="{ height: `calc((100% - 12px) * ${effectiveVolume})` }"
939
+ />
940
+ <span
941
+ class="absolute left-1/2 size-3 -translate-x-1/2 translate-y-1/2 rounded-full bg-white group-focus-visible/volume:ring-2 group-focus-visible/volume:ring-white group-focus-visible/volume:outline-2 group-focus-visible/volume:outline-offset-2 group-focus-visible/volume:outline-blue-600"
942
+ :style="{ bottom: knobBottom(effectiveVolume) }"
943
+ />
944
+ </span>
945
+ </span>
946
+ </span>
947
+ </span>
948
+
949
+ <span v-if="fullscreenSupported" class="shrink-0 @max-[10rem]:hidden">
950
+ <button
951
+ type="button"
952
+ :aria-label="fullscreen ? copy.exitFullscreen : copy.enterFullscreen"
953
+ class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full small-normal transition-[background-color] duration-150 ease-out hover:bg-white/15 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"
954
+ @click="toggleFullscreen"
955
+ >
956
+ <NexxtIcon :name="fullscreen ? 'compress' : 'expand'" type="solid" />
957
+ </button>
958
+ </span>
959
+ </div>
960
+ </div>
961
+ </template>