From 533edc5bbd9ff923bf3bb4b5fa31b81afce98062 Mon Sep 17 00:00:00 2001 From: Rahim Alwer Date: Mon, 26 Feb 2024 18:12:44 +1100 Subject: [PATCH] feat(player): media announcer --- packages/react/src/components/announcer.tsx | 47 ++++ .../layouts/default/audio-layout.tsx | 4 +- .../src/components/layouts/default/index.ts | 9 +- .../default/keyboard-action-display.tsx | 226 ------------------ .../layouts/default/keyboard-display.tsx | 134 +++++++++++ .../layouts/default/shared-layout.tsx | 13 + .../layouts/default/video-layout.tsx | 19 +- .../src/components/primitives/instances.ts | 2 + packages/react/src/index.ts | 1 + .../player/styles/default/keyboard.css | 8 - .../vidstack/src/components/aria/announcer.ts | 179 ++++++++++++++ packages/vidstack/src/components/index.ts | 1 + packages/vidstack/src/components/player.ts | 2 +- .../ui/sliders/time-slider/time-slider.ts | 4 + .../elements/bundles/cdn/player-with-plyr.ts | 1 - .../src/elements/bundles/player-ui.ts | 2 + .../src/elements/define/announcer-element.ts | 20 ++ .../define/layouts/default/audio-layout.ts | 4 +- ...-action-display.ts => keyboard-display.ts} | 97 ++------ .../define/layouts/default/shared-layout.ts | 5 + .../define/layouts/default/video-layout.ts | 5 +- packages/vidstack/src/elements/index.ts | 1 + 22 files changed, 443 insertions(+), 341 deletions(-) create mode 100644 packages/react/src/components/announcer.tsx delete mode 100644 packages/react/src/components/layouts/default/keyboard-action-display.tsx create mode 100644 packages/react/src/components/layouts/default/keyboard-display.tsx create mode 100644 packages/vidstack/src/components/aria/announcer.ts create mode 100644 packages/vidstack/src/elements/define/announcer-element.ts rename packages/vidstack/src/elements/define/layouts/default/{keyboard-action-display.ts => keyboard-display.ts} (57%) diff --git a/packages/react/src/components/announcer.tsx b/packages/react/src/components/announcer.tsx new file mode 100644 index 000000000..52e255c47 --- /dev/null +++ b/packages/react/src/components/announcer.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { composeRefs, createReactComponent, type ReactElementProps } from 'maverick.js/react'; + +import { MediaAnnouncerInstance } from './primitives/instances'; +import { Primitive } from './primitives/nodes'; + +/* ------------------------------------------------------------------------------------------------- + * MediaAnnouncer + * -----------------------------------------------------------------------------------------------*/ + +const MediaAnnouncerBridge = createReactComponent(MediaAnnouncerInstance, { + events: ['onChange'], +}); + +export interface MediaAnnouncerProps extends ReactElementProps { + ref?: React.Ref; +} + +/** + * + * @docs {@link https://www.vidstack.io/docs/player/components/display/announcer} + * @example + * ```tsx + * + * ``` + */ +const MediaAnnouncer = React.forwardRef( + ({ style, children, ...props }, forwardRef) => { + return ( + )}> + {(props) => ( + + {children} + + )} + + ); + }, +); + +MediaAnnouncer.displayName = 'MediaAnnouncer'; +export { MediaAnnouncer }; diff --git a/packages/react/src/components/layouts/default/audio-layout.tsx b/packages/react/src/components/layouts/default/audio-layout.tsx index 51440fbdf..a721bc733 100644 --- a/packages/react/src/components/layouts/default/audio-layout.tsx +++ b/packages/react/src/components/layouts/default/audio-layout.tsx @@ -17,9 +17,9 @@ import { createComputed } from '../../../hooks/use-signals'; import * as Controls from '../../ui/controls'; import { useLayoutName } from '../utils'; import { i18n, useDefaultLayoutContext } from './context'; -import { DefaultKeyboardStatus } from './keyboard-action-display'; import { createDefaultMediaLayout, type DefaultLayoutProps } from './media-layout'; import { + DefaultAnnouncer, DefaultCaptionButton, DefaultCaptions, DefaultChaptersMenu, @@ -112,8 +112,8 @@ function AudioLayout() { const slots = useDefaultAudioLayoutSlots(); return ( <> + - {slot(slots, 'seekBackwardButton', )} diff --git a/packages/react/src/components/layouts/default/index.ts b/packages/react/src/components/layouts/default/index.ts index 59758e61b..cb540f946 100644 --- a/packages/react/src/components/layouts/default/index.ts +++ b/packages/react/src/components/layouts/default/index.ts @@ -10,11 +10,4 @@ export type { DefaultLayoutProps } from './media-layout'; export * from './icons'; export * from './context'; export * from './ui'; -export { - DefaultKeyboardDisplay, - DefaultKeyboardStatus, - type DefaultKeyboardDisplayProps, - type DefaultKeyboardDisplayWords, - type DefaultKeyboardDisplayTranslations, - type DefaultKeyboardStatusProps, -} from './keyboard-action-display'; +export { DefaultKeyboardDisplay, type DefaultKeyboardDisplayProps } from './keyboard-display'; diff --git a/packages/react/src/components/layouts/default/keyboard-action-display.tsx b/packages/react/src/components/layouts/default/keyboard-action-display.tsx deleted file mode 100644 index 8b6ca3687..000000000 --- a/packages/react/src/components/layouts/default/keyboard-action-display.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import * as React from 'react'; - -import { useContext } from 'maverick.js'; -import { useSignal } from 'maverick.js/react'; -import { camelToKebabCase } from 'maverick.js/std'; -import { mediaContext, type DefaultLayoutTranslations } from 'vidstack'; - -import { useMediaState } from '../../../hooks/use-media-state'; -import { createComputed, createEffect } from '../../../hooks/use-signals'; -import { Primitive, type PrimitivePropsWithRef } from '../../primitives/nodes'; -import { i18n, useDefaultLayoutContext } from './context'; -import type { DefaultKeyboardActionIcons } from './icons'; - -export type DefaultKeyboardDisplayWords = - | 'Play' - | 'Pause' - | 'Enter Fullscreen' - | 'Exit Fullscreen' - | 'Enter PiP' - | 'Exit PiP' - | 'Closed-Captions On' - | 'Closed-Captions Off' - | 'Mute' - | 'Volume' - | 'Seek Forward' - | 'Seek Backward'; - -export interface DefaultKeyboardDisplayTranslations - extends Pick {} - -export interface DefaultKeyboardDisplayProps - extends Omit, 'disabled'> { - icons?: DefaultKeyboardActionIcons; - translations?: Partial | null; -} - -const DefaultKeyboardDisplay = React.forwardRef( - ({ icons: Icons, translations, ...props }, forwardRef) => { - const [visible, setVisible] = React.useState(false), - [Icon, setIcon] = React.useState(null), - [count, setCount] = React.useState(0), - $lastKeyboardAction = useMediaState('lastKeyboardAction'); - - React.useEffect(() => { - setCount((n) => n + 1); - }, [$lastKeyboardAction]); - - const actionDataAttr = React.useMemo(() => { - const action = $lastKeyboardAction?.action; - return action && visible ? camelToKebabCase(action) : null; - }, [visible, $lastKeyboardAction]); - - const className = React.useMemo( - () => - `vds-kb-action${!visible ? ' hidden' : ''}${props.className ? ` ${props.className}` : ''}`, - [visible], - ); - - const $$text = createComputed(getText), - $text = useSignal($$text); - - createEffect(() => { - const Icon = getIcon(Icons); - setIcon(() => Icon); - }, [Icons]); - - React.useEffect(() => { - setVisible(!!$lastKeyboardAction); - const id = setTimeout(() => setVisible(false), 500); - return () => { - setVisible(false); - window.clearTimeout(id); - }; - }, [$lastKeyboardAction]); - - return ( - -
-
{$text}
-
- - {Icon ? ( -
- -
- ) : null} -
-
- ); - }, -); - -DefaultKeyboardDisplay.displayName = 'DefaultKeyboardDisplay'; -export { DefaultKeyboardDisplay }; - -function getText() { - const { $state } = useContext(mediaContext), - action = $state.lastKeyboardAction()?.action, - audioGain = $state.audioGain() ?? 1; - switch (action) { - case 'toggleMuted': - return $state.muted() ? '0%' : getVolumeText($state.volume(), audioGain); - case 'volumeUp': - case 'volumeDown': - return getVolumeText($state.volume(), audioGain); - default: - return ''; - } -} - -function getVolumeText(volume: number, gain: number) { - return `${Math.round(volume * gain * 100)}%`; -} - -function getIcon(Icons?: DefaultKeyboardActionIcons) { - const { $state } = useContext(mediaContext), - action = $state.lastKeyboardAction()?.action; - switch (action) { - case 'togglePaused': - return !$state.paused() ? Icons?.Play : Icons?.Pause; - case 'toggleMuted': - return $state.muted() || $state.volume() === 0 - ? Icons?.Mute - : $state.volume() >= 0.5 - ? Icons?.VolumeUp - : Icons?.VolumeDown; - case 'toggleFullscreen': - return $state.fullscreen() ? Icons?.EnterFullscreen : Icons?.ExitFullscreen; - case 'togglePictureInPicture': - return $state.pictureInPicture() ? Icons?.EnterPiP : Icons?.ExitPiP; - case 'toggleCaptions': - return $state.hasCaptions() - ? $state.textTrack() - ? Icons?.CaptionsOn - : Icons?.CaptionsOff - : null; - case 'volumeUp': - return Icons?.VolumeUp; - case 'volumeDown': - return Icons?.VolumeDown; - case 'seekForward': - return Icons?.SeekForward; - case 'seekBackward': - return Icons?.SeekBackward; - default: - return null; - } -} - -/* ------------------------------------------------------------------------------------------------- - * DefaultKeyboardStatus - * -----------------------------------------------------------------------------------------------*/ - -export interface DefaultKeyboardStatusProps extends PrimitivePropsWithRef<'div'> {} - -const DefaultKeyboardStatus = React.forwardRef( - ({ children, ...props }, forwardRef) => { - const { translations } = useDefaultLayoutContext(), - [isBusy, setIsBusy] = React.useState(false), - $$statusLabel = createComputed(() => getStatusLabel(translations!), [translations]), - $statusLabel = useSignal($$statusLabel); - - React.useEffect(() => { - setIsBusy(true); - - const id = window.setTimeout(() => { - setIsBusy(false); - }, 150); - - return () => window.clearTimeout(id); - }, [$statusLabel]); - - return ( -
- {children} -
- ); - }, -); - -DefaultKeyboardStatus.displayName = 'DefaultKeyboardStatus'; -export { DefaultKeyboardStatus }; - -function getStatusLabel(translations?: Partial) { - const text = getStatusText(translations); - return text ? i18n(translations, text) : null; -} - -function getStatusText(translations?: Partial): any { - const { $state } = useContext(mediaContext), - action = $state.lastKeyboardAction()?.action; - switch (action) { - case 'togglePaused': - return !$state.paused() ? 'Play' : 'Pause'; - case 'toggleFullscreen': - return $state.fullscreen() ? 'Enter Fullscreen' : 'Exit Fullscreen'; - case 'togglePictureInPicture': - return $state.pictureInPicture() ? 'Enter PiP' : 'Exit PiP'; - case 'toggleCaptions': - return $state.textTrack() ? 'Closed-Captions On' : 'Closed-Captions Off'; - case 'toggleMuted': - case 'volumeUp': - case 'volumeDown': - return $state.muted() || $state.volume() === 0 - ? 'Mute' - : `${Math.round($state.volume() * ($state.audioGain() ?? 1) * 100)}% ${i18n(translations, 'Volume')}`; - case 'seekForward': - return 'Seek Forward'; - case 'seekBackward': - return 'Seek Backward'; - default: - return null; - } -} diff --git a/packages/react/src/components/layouts/default/keyboard-display.tsx b/packages/react/src/components/layouts/default/keyboard-display.tsx new file mode 100644 index 000000000..d5ba30b27 --- /dev/null +++ b/packages/react/src/components/layouts/default/keyboard-display.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; + +import { useContext } from 'maverick.js'; +import { useSignal } from 'maverick.js/react'; +import { camelToKebabCase } from 'maverick.js/std'; +import { mediaContext } from 'vidstack'; + +import { useMediaState } from '../../../hooks/use-media-state'; +import { createComputed, createEffect } from '../../../hooks/use-signals'; +import { Primitive, type PrimitivePropsWithRef } from '../../primitives/nodes'; +import type { DefaultKeyboardActionIcons } from './icons'; + +export interface DefaultKeyboardDisplayProps + extends Omit, 'disabled'> { + icons: DefaultKeyboardActionIcons; +} + +const DefaultKeyboardDisplay = React.forwardRef( + ({ icons: Icons, ...props }, forwardRef) => { + const [visible, setVisible] = React.useState(false), + [Icon, setIcon] = React.useState(null), + [count, setCount] = React.useState(0), + $lastKeyboardAction = useMediaState('lastKeyboardAction'); + + React.useEffect(() => { + setCount((n) => n + 1); + }, [$lastKeyboardAction]); + + const actionDataAttr = React.useMemo(() => { + const action = $lastKeyboardAction?.action; + return action && visible ? camelToKebabCase(action) : null; + }, [visible, $lastKeyboardAction]); + + const className = React.useMemo( + () => + `vds-kb-action${!visible ? ' hidden' : ''}${props.className ? ` ${props.className}` : ''}`, + [visible], + ); + + const $$text = createComputed(getText), + $text = useSignal($$text); + + createEffect(() => { + const Icon = getIcon(Icons); + setIcon(() => Icon); + }, [Icons]); + + React.useEffect(() => { + setVisible(!!$lastKeyboardAction); + const id = setTimeout(() => setVisible(false), 500); + return () => { + setVisible(false); + window.clearTimeout(id); + }; + }, [$lastKeyboardAction]); + + return ( + +
+
{$text}
+
+
+ {Icon ? ( +
+ +
+ ) : null} +
+
+ ); + }, +); + +DefaultKeyboardDisplay.displayName = 'DefaultKeyboardDisplay'; +export { DefaultKeyboardDisplay }; + +function getText() { + const { $state } = useContext(mediaContext), + action = $state.lastKeyboardAction()?.action, + audioGain = $state.audioGain() ?? 1; + switch (action) { + case 'toggleMuted': + return $state.muted() ? '0%' : getVolumeText($state.volume(), audioGain); + case 'volumeUp': + case 'volumeDown': + return getVolumeText($state.volume(), audioGain); + default: + return ''; + } +} + +function getVolumeText(volume: number, gain: number) { + return `${Math.round(volume * gain * 100)}%`; +} + +function getIcon(Icons: DefaultKeyboardActionIcons) { + const { $state } = useContext(mediaContext), + action = $state.lastKeyboardAction()?.action; + switch (action) { + case 'togglePaused': + return !$state.paused() ? Icons.Play : Icons.Pause; + case 'toggleMuted': + return $state.muted() || $state.volume() === 0 + ? Icons.Mute + : $state.volume() >= 0.5 + ? Icons.VolumeUp + : Icons.VolumeDown; + case 'toggleFullscreen': + return $state.fullscreen() ? Icons.EnterFullscreen : Icons.ExitFullscreen; + case 'togglePictureInPicture': + return $state.pictureInPicture() ? Icons.EnterPiP : Icons.ExitPiP; + case 'toggleCaptions': + return $state.hasCaptions() + ? $state.textTrack() + ? Icons.CaptionsOn + : Icons.CaptionsOff + : null; + case 'volumeUp': + return Icons.VolumeUp; + case 'volumeDown': + return Icons.VolumeDown; + case 'seekForward': + return Icons.SeekForward; + case 'seekBackward': + return Icons.SeekBackward; + default: + return null; + } +} diff --git a/packages/react/src/components/layouts/default/shared-layout.tsx b/packages/react/src/components/layouts/default/shared-layout.tsx index a7eda4686..c0375b4ca 100644 --- a/packages/react/src/components/layouts/default/shared-layout.tsx +++ b/packages/react/src/components/layouts/default/shared-layout.tsx @@ -14,6 +14,7 @@ import { useMediaContext } from '../../../hooks/use-media-context'; import { useMediaState } from '../../../hooks/use-media-state'; import { createComputed } from '../../../hooks/use-signals'; import { isRemotionSource } from '../../../providers/remotion/type-check'; +import { MediaAnnouncer } from '../../announcer'; import type { TimeSliderInstance } from '../../primitives/instances'; import { AirPlayButton } from '../../ui/buttons/airplay-button'; import { CaptionButton } from '../../ui/buttons/caption-button'; @@ -55,6 +56,18 @@ interface DefaultMediaMenuProps { slots?: Slots; } +/* ------------------------------------------------------------------------------------------------- + * DefaultAnnouncer + * -----------------------------------------------------------------------------------------------*/ + +function DefaultAnnouncer() { + const { translations } = useDefaultLayoutContext(); + return ; +} + +DefaultAnnouncer.displayName = 'DefaultAnnouncer'; +export { DefaultAnnouncer }; + /* ------------------------------------------------------------------------------------------------- * DefaultTooltip * -----------------------------------------------------------------------------------------------*/ diff --git a/packages/react/src/components/layouts/default/video-layout.tsx b/packages/react/src/components/layouts/default/video-layout.tsx index a55bd9dbe..62463c304 100644 --- a/packages/react/src/components/layouts/default/video-layout.tsx +++ b/packages/react/src/components/layouts/default/video-layout.tsx @@ -9,10 +9,11 @@ import * as Spinner from '../../ui/spinner'; import { Time } from '../../ui/time'; import { useLayoutName } from '../utils'; import { useDefaultLayoutContext } from './context'; -import { DefaultKeyboardDisplay, DefaultKeyboardStatus } from './keyboard-action-display'; +import { DefaultKeyboardDisplay } from './keyboard-display'; import { createDefaultMediaLayout, type DefaultLayoutProps } from './media-layout'; import { DefaultAirPlayButton, + DefaultAnnouncer, DefaultCaptionButton, DefaultCaptions, DefaultChaptersMenu, @@ -93,6 +94,7 @@ function DefaultVideoLargeLayout() { slots = { ...baseSlots, ...baseSlots?.largeLayout }; return ( <> + {slot(slots, 'bufferingIndicator', )} @@ -153,6 +155,7 @@ function DefaultVideoSmallLayout() { slots = { ...baseSlots, ...baseSlots?.smallLayout }; return ( <> + {slot(slots, 'bufferingIndicator', )} @@ -316,15 +319,13 @@ DefaultVideoLoadLayout.displayName = 'DefaultVideoLoadLayout'; * -----------------------------------------------------------------------------------------------*/ function DefaultVideoKeyboardDisplay() { - const { noKeyboardAnimations, icons, translations, userPrefersKeyboardAnimations } = - useDefaultLayoutContext(), + const { noKeyboardAnimations, icons, userPrefersKeyboardAnimations } = useDefaultLayoutContext(), $userPrefersKeyboardAnimations = useSignal(userPrefersKeyboardAnimations), - noAnimations = noKeyboardAnimations || !$userPrefersKeyboardAnimations; - return noAnimations ? ( - - ) : ( - - ); + disabled = noKeyboardAnimations || !$userPrefersKeyboardAnimations; + + if (disabled || !icons.KeyboardAction) return null; + + return ; } DefaultVideoKeyboardDisplay.displayName = 'DefaultVideoKeyboardDisplay'; diff --git a/packages/react/src/components/primitives/instances.ts b/packages/react/src/components/primitives/instances.ts index 0fccc7172..bde7462c8 100644 --- a/packages/react/src/components/primitives/instances.ts +++ b/packages/react/src/components/primitives/instances.ts @@ -9,6 +9,7 @@ import { Gesture, GoogleCastButton, LiveButton, + MediaAnnouncer, MediaPlayer, MediaProvider, Menu, @@ -42,6 +43,7 @@ import { // Core export class MediaPlayerInstance extends MediaPlayer {} export class MediaProviderInstance extends MediaProvider {} +export class MediaAnnouncerInstance extends MediaAnnouncer {} // Controls export class ControlsInstance extends Controls {} export class ControlsGroupInstance extends ControlsGroup {} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4dbca43fc..52edb6204 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ export * from './components/primitives/instances'; // Core export type { PlayerSrc } from './source'; export { type MediaPlayerProps, MediaPlayer } from './components/player'; +export { type MediaAnnouncerProps, MediaAnnouncer } from './components/announcer'; export { type MediaProviderProps, MediaProvider } from './components/provider'; export { type IconProps, Icon, type IconComponent } from './icon'; export { Track, type TrackProps } from './components/text-track'; diff --git a/packages/vidstack/player/styles/default/keyboard.css b/packages/vidstack/player/styles/default/keyboard.css index 9f3d8c80f..32d68a30d 100644 --- a/packages/vidstack/player/styles/default/keyboard.css +++ b/packages/vidstack/player/styles/default/keyboard.css @@ -8,14 +8,6 @@ opacity: 0; } -:where(.vds-sr-only) { - position: absolute; - height: 1px; - width: 1px; - overflow: hidden; - clip: rect(1px, 1px, 1px, 1px); -} - /* * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Keyboard Text diff --git a/packages/vidstack/src/components/aria/announcer.ts b/packages/vidstack/src/components/aria/announcer.ts new file mode 100644 index 000000000..99de25f56 --- /dev/null +++ b/packages/vidstack/src/components/aria/announcer.ts @@ -0,0 +1,179 @@ +import { Component, effect, peek, State, tick } from 'maverick.js'; +import { isString, setAttribute, type DOMEvent } from 'maverick.js/std'; + +import { useMediaContext, type MediaContext } from '../../core/api/media-context'; +import { setAttributeIfEmpty } from '../../utils/dom'; +import { formatSpokenTime } from '../../utils/time'; + +/** + * @docs {@link https://www.vidstack.io/docs/player/components/display/announcer} + */ +export class MediaAnnouncer extends Component< + MediaAnnouncerProps, + MediaAnnouncerState, + MediaAnnouncerEvents +> { + static props: MediaAnnouncerProps = { + translations: null, + }; + + static state = new State({ + label: null, + busy: false, + }); + + private _media!: MediaContext; + private _initializing = false; + + protected override onSetup(): void { + this._media = useMediaContext(); + } + + protected override onAttach(el: HTMLElement): void { + // sr-only. + el.style.display = 'contents'; + } + + protected override onConnect(el: HTMLElement): void { + el.setAttribute('data-media-announcer', ''); + + setAttributeIfEmpty(el, 'role', 'status'); + setAttributeIfEmpty(el, 'aria-live', 'polite'); + + const { busy } = this.$state; + this.setAttributes({ + 'aria-busy': () => (busy() ? 'true' : null), + }); + + // Avoid triggering label updates on first run. + this._initializing = true; + + effect(this._watchPaused.bind(this)); + effect(this._watchVolume.bind(this)); + effect(this._watchCaptions.bind(this)); + effect(this._watchFullscreen.bind(this)); + effect(this._watchPiP.bind(this)); + effect(this._watchSeeking.bind(this)); + effect(this._watchLabel.bind(this)); + + tick(); + this._initializing = false; + } + + private _watchPaused() { + const { paused } = this._media.$state; + this._setLabel(!paused() ? 'Play' : 'Pause'); + } + + private _watchFullscreen() { + const { fullscreen } = this._media.$state; + this._setLabel(fullscreen() ? 'Enter Fullscreen' : 'Exit Fullscreen'); + } + + private _watchPiP() { + const { pictureInPicture } = this._media.$state; + this._setLabel(pictureInPicture() ? 'Enter PiP' : 'Exit PiP'); + } + + private _watchCaptions() { + const { textTrack } = this._media.$state; + this._setLabel(textTrack() ? 'Closed-Captions On' : 'Closed-Captions Off'); + } + + private _watchVolume() { + const { muted, volume, audioGain } = this._media.$state; + this._setLabel( + muted() || volume() === 0 + ? 'Mute' + : `${Math.round(volume() * (audioGain() ?? 1) * 100)}% ${this._translate('Volume')}`, + ); + } + + private _startedSeekingAt = -1; + private _seekTimer = -1; + private _watchSeeking() { + const { seeking, currentTime } = this._media.$state, + isSeeking = seeking(); + + if (this._startedSeekingAt > 0) { + window.clearTimeout(this._seekTimer); + this._seekTimer = window.setTimeout(() => { + const newTime = peek(currentTime), + seconds = Math.abs(newTime - this._startedSeekingAt); + + if (seconds >= 1) { + const isForward = newTime >= this._startedSeekingAt, + spokenTime = formatSpokenTime(seconds); + + this._setLabel( + `${this._translate(isForward ? 'Seek Forward' : 'Seek Backward')} ${spokenTime}`, + ); + } + + this._startedSeekingAt = -1; + this._seekTimer = -1; + }, 300); + } else if (isSeeking) { + this._startedSeekingAt = peek(currentTime); + } + } + + private _translate(word: string | null) { + const { translations } = this.$props; + return translations()?.[word || ''] ?? word; + } + + private _watchLabel() { + const { label, busy } = this.$state, + $label = this._translate(label()); + + if (this._initializing) return; + + busy.set(true); + const id = window.setTimeout(() => void busy.set(false), 150); + + this.el && setAttribute(this.el, 'aria-label', $label); + + if (isString($label)) { + this.dispatch('change', { detail: $label }); + } + + return () => window.clearTimeout(id); + } + + private _setLabel(word: string | null) { + const { label } = this.$state; + label.set(word); + } +} + +export interface MediaAnnouncerProps { + translations: Partial | null; +} + +export interface MediaAnnouncerState { + label: string | null; + busy: boolean; +} + +export interface MediaAnnouncerEvents { + change: DOMEvent; +} + +export type MediaAnnouncerWord = + | 'Play' + | 'Pause' + | 'Enter Fullscreen' + | 'Exit Fullscreen' + | 'Enter PiP' + | 'Exit PiP' + | 'Closed-Captions On' + | 'Closed-Captions Off' + | 'Mute' + | 'Volume' + | 'Seek Forward' + | 'Seek Backward'; + +export type MediaAnnouncerTranslations = { + [word in MediaAnnouncerWord]: string; +}; diff --git a/packages/vidstack/src/components/index.ts b/packages/vidstack/src/components/index.ts index 9c8af4356..83f427c25 100644 --- a/packages/vidstack/src/components/index.ts +++ b/packages/vidstack/src/components/index.ts @@ -1,5 +1,6 @@ export * from './player'; export * from './provider/provider'; +export * from './aria/announcer'; // Controls export * from './ui/controls'; diff --git a/packages/vidstack/src/components/player.ts b/packages/vidstack/src/components/player.ts index 0e9e29a89..636688ac2 100644 --- a/packages/vidstack/src/components/player.ts +++ b/packages/vidstack/src/components/player.ts @@ -267,7 +267,7 @@ export class MediaPlayer setAttribute( this.el!, 'aria-label', - currentTitle ? `${typeText} - ${currentTitle}` : typeText + ' Player', + `${typeText} Player` + (currentTitle ? `- ${currentTitle}` : ''), ); // Title attribute is removed to prevent popover interfering with user hovering over player. diff --git a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts index 9757499e8..f4e823908 100644 --- a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts +++ b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts @@ -184,6 +184,10 @@ export class TimeSlider extends Component< } private _onDragEnd(event: SliderValueChangeEvent | SliderDragEndEvent) { + // Ensure a seeking event is always fired before a seeked event for consistency. + const { seeking } = this._media.$state; + if (!peek(seeking)) this._seeking(this._percentToTime(event.detail), event); + const percent = event.detail; this._seek(this._percentToTime(percent), percent, event); diff --git a/packages/vidstack/src/elements/bundles/cdn/player-with-plyr.ts b/packages/vidstack/src/elements/bundles/cdn/player-with-plyr.ts index f1746608b..09c371f0f 100644 --- a/packages/vidstack/src/elements/bundles/cdn/player-with-plyr.ts +++ b/packages/vidstack/src/elements/bundles/cdn/player-with-plyr.ts @@ -1,4 +1,3 @@ import '../player'; import '../player-layouts/plyr'; -import '../player-ui'; import '../icons'; diff --git a/packages/vidstack/src/elements/bundles/player-ui.ts b/packages/vidstack/src/elements/bundles/player-ui.ts index ce4e46ecc..ab6fe3493 100644 --- a/packages/vidstack/src/elements/bundles/player-ui.ts +++ b/packages/vidstack/src/elements/bundles/player-ui.ts @@ -1,5 +1,6 @@ import { defineCustomElement } from 'maverick.js/element'; +import { MediaAnnouncerElement } from '../define/announcer-element'; import { MediaAirPlayButtonElement } from '../define/buttons/airplay-button-element'; import { MediaCaptionButtonElement } from '../define/buttons/caption-button-element'; import { MediaFullscreenButtonElement } from '../define/buttons/fullscreen-button-element'; @@ -51,6 +52,7 @@ defineCustomElement(MediaLayoutElement); defineCustomElement(MediaControlsElement); defineCustomElement(MediaControlsGroupElement); defineCustomElement(MediaPosterElement); +defineCustomElement(MediaAnnouncerElement); // Tooltips defineCustomElement(MediaTooltipElement); defineCustomElement(MediaTooltipTriggerElement); diff --git a/packages/vidstack/src/elements/define/announcer-element.ts b/packages/vidstack/src/elements/define/announcer-element.ts new file mode 100644 index 000000000..5166b6569 --- /dev/null +++ b/packages/vidstack/src/elements/define/announcer-element.ts @@ -0,0 +1,20 @@ +import { Host } from 'maverick.js/element'; + +import { MediaAnnouncer } from '../../components'; + +/** + * @docs {@link https://www.vidstack.io/docs/wc/player/components/display/announcer} + * @example + * ```html + * + * ``` + */ +export class MediaAnnouncerElement extends Host(HTMLElement, MediaAnnouncer) { + static tagName = 'media-announcer'; +} + +declare global { + interface HTMLElementTagNameMap { + 'media-announcer': MediaAnnouncerElement; + } +} diff --git a/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts b/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts index 025e01f69..a514bdace 100644 --- a/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts @@ -14,8 +14,8 @@ import { useTransitionActive, } from '../../../../utils/dom'; import { $signal } from '../../../lit/directives/signal'; -import { DefaultKeyboardStatus } from './keyboard-action-display'; import { + DefaultAnnouncer, DefaultCaptionButton, DefaultCaptions, DefaultChaptersMenu, @@ -32,8 +32,8 @@ import { export function DefaultAudioLayout() { return [ + DefaultAnnouncer(), DefaultCaptions(), - DefaultKeyboardStatus({ className: 'vds-sr-only' }), html` diff --git a/packages/vidstack/src/elements/define/layouts/default/keyboard-action-display.ts b/packages/vidstack/src/elements/define/layouts/default/keyboard-display.ts similarity index 57% rename from packages/vidstack/src/elements/define/layouts/default/keyboard-action-display.ts rename to packages/vidstack/src/elements/define/layouts/default/keyboard-display.ts index 916371fee..dcd6ae22e 100644 --- a/packages/vidstack/src/elements/define/layouts/default/keyboard-action-display.ts +++ b/packages/vidstack/src/elements/define/layouts/default/keyboard-display.ts @@ -4,23 +4,23 @@ import { computed, effect, signal } from 'maverick.js'; import { camelToKebabCase } from 'maverick.js/std'; import { useDefaultLayoutContext } from '../../../../components/layouts/default/context'; -import { i18n } from '../../../../components/layouts/default/translations'; import { useMediaContext } from '../../../../core/api/media-context'; import { createSlot } from '../../../../utils/dom'; import { $signal } from '../../../lit/directives/signal'; export function DefaultKeyboardDisplay() { return $signal(() => { - const visible = signal(false), - media = useMediaContext(), + const media = useMediaContext(), { noKeyboardAnimations, userPrefersKeyboardAnimations } = useDefaultLayoutContext(), - { lastKeyboardAction } = media.$state, - $isAnimated = computed(() => !noKeyboardAnimations() && userPrefersKeyboardAnimations()); + $disabled = computed(() => noKeyboardAnimations() || !userPrefersKeyboardAnimations()); - if (!$isAnimated()) { - return DefaultKeyboardStatus({ className: 'vds-sr-only' }); + if ($disabled()) { + return null; } + const visible = signal(false), + { lastKeyboardAction } = media.$state; + effect(() => { visible.set(!!lastKeyboardAction()); const id = setTimeout(() => visible.set(false), 500); @@ -43,14 +43,13 @@ export function DefaultKeyboardDisplay() { }); function Icon() { - return DefaultKeyboardStatus({ - className: 'vds-kb-bezel', - children: $signal(() => { - const $slot = $iconSlot(); - if (!$slot) return null; - return html`
${$slot}
`; - }), - }); + const $slot = $iconSlot(); + if (!$slot) return null; + return html` +
+
${$slot}
+
+ `; } return html` @@ -64,40 +63,6 @@ export function DefaultKeyboardDisplay() { }); } -export function DefaultKeyboardStatus({ - className = null, - children = null, -}: { - className?: string | null; - children?: any; -} = {}) { - const $statusLabel = computed(getStatusLabel), - $busy = signal(false); - - effect(() => { - $statusLabel(); - $busy.set(true); - - const id = window.setTimeout(() => { - $busy.set(false); - }, 150); - - return () => window.clearTimeout(id); - }); - - return html` -
($busy() ? 'true' : null))} - aria-label=${$signal($statusLabel)} - > - ${children} -
- `; -} - function getText() { const { $state } = useMediaContext(), action = $state.lastKeyboardAction()?.action, @@ -147,37 +112,3 @@ function getIconName() { return null; } } - -function getStatusLabel() { - const $text = getStatusText(), - { translations } = useDefaultLayoutContext(); - return $text ? i18n(translations, $text) : null; -} - -function getStatusText(): any { - const { $state } = useMediaContext(), - action = $state.lastKeyboardAction()?.action, - { translations } = useDefaultLayoutContext(); - switch (action) { - case 'togglePaused': - return !$state.paused() ? 'Play' : 'Pause'; - case 'toggleFullscreen': - return $state.fullscreen() ? 'Enter Fullscreen' : 'Exit Fullscreen'; - case 'togglePictureInPicture': - return $state.pictureInPicture() ? 'Enter PiP' : 'Exit PiP'; - case 'toggleCaptions': - return $state.textTrack() ? 'Closed-Captions On' : 'Closed-Captions Off'; - case 'toggleMuted': - case 'volumeUp': - case 'volumeDown': - return $state.muted() || $state.volume() === 0 - ? 'Mute' - : `${Math.round($state.volume() * ($state.audioGain() ?? 1) * 100)}% ${translations()?.Volume ?? 'Volume'}`; - case 'seekForward': - return 'Seek Forward'; - case 'seekBackward': - return 'Seek Backward'; - default: - return null; - } -} diff --git a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts index 6d8280efb..24eeecae8 100644 --- a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts @@ -22,6 +22,11 @@ import { $signal } from '../../../lit/directives/signal'; import { DefaultFontMenu } from './font-menu'; import { renderMenuButton } from './menu-layout'; +export function DefaultAnnouncer() { + const { translations } = useDefaultLayoutContext(); + return html``; +} + export function DefaultAirPlayButton({ tooltip }: { tooltip: TooltipPlacement }) { const { translations } = useDefaultLayoutContext(), { remotePlaybackState } = useMediaState(), diff --git a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts index 26102547f..cdf8dc6e0 100644 --- a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts @@ -4,9 +4,10 @@ import { computed } from 'maverick.js'; import { useDefaultLayoutContext } from '../../../../components/layouts/default/context'; import { useMediaState } from '../../../../core/api/media-context'; import { $signal } from '../../../lit/directives/signal'; -import { DefaultKeyboardDisplay } from './keyboard-action-display'; +import { DefaultKeyboardDisplay } from './keyboard-display'; import { DefaultAirPlayButton, + DefaultAnnouncer, DefaultCaptionButton, DefaultCaptions, DefaultChaptersMenu, @@ -25,6 +26,7 @@ import { export function DefaultVideoLayoutLarge() { return [ + DefaultAnnouncer(), DefaultVideoGestures(), DefaultBufferingIndicator(), DefaultKeyboardDisplay(), @@ -85,6 +87,7 @@ function DefaultControlsGroupTop() { export function DefaultVideoLayoutSmall() { return [ + DefaultAnnouncer(), DefaultVideoGestures(), DefaultBufferingIndicator(), DefaultCaptions(), diff --git a/packages/vidstack/src/elements/index.ts b/packages/vidstack/src/elements/index.ts index 4bee93d72..2e42a47b8 100644 --- a/packages/vidstack/src/elements/index.ts +++ b/packages/vidstack/src/elements/index.ts @@ -4,6 +4,7 @@ export { MediaCaptionsElement } from './define/captions-element'; export { MediaGestureElement } from './define/gesture-element'; export { MediaProviderElement } from './define/provider-element'; export { MediaPlayerElement } from './define/player-element'; +export { MediaAnnouncerElement } from './define/announcer-element'; export { MediaPosterElement } from './define/poster-element'; export { MediaThumbnailElement } from './define/thumbnail-element'; export { MediaTimeElement } from './define/time-element';