feat(core): add feature availability primitive

This commit is contained in:
Rahim
2026-06-24 23:28:07 -07:00
parent 3e61ef65a9
commit 7b05ba4588
21 changed files with 503 additions and 1 deletions
+1
View File
@@ -25,6 +25,7 @@ export * from './ui/controls/controls-data-attrs';
export * from './ui/error-dialog/error-dialog-core';
export * from './ui/error-dialog/error-dialog-data-attrs';
export * from './ui/error-dialog/props';
export * from './ui/feature-availability/props';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/fullscreen-button/props';
@@ -10,6 +10,7 @@ import CastButtonDef from './cast-button/cast-button-component';
import ContainerDef from './container/container-component';
import ControlsDef from './controls/controls-component';
import ErrorDialogDef from './error-dialog/error-dialog-component';
import FeatureAvailabilityDef from './feature-availability/feature-availability-component';
import FullscreenButtonDef from './fullscreen-button/fullscreen-button-component';
import GestureDef from './gesture/gesture-component';
import HotkeyDef from './hotkey/hotkey-component';
@@ -46,6 +47,7 @@ export const CastButton = createComponent(CastButtonDef);
export const Container = createComponent(ContainerDef);
export const Controls = createComponent(ControlsDef);
export const ErrorDialog = createComponent(ErrorDialogDef);
export const FeatureAvailability = createComponent(FeatureAvailabilityDef);
export const FullscreenButton = createComponent(FullscreenButtonDef);
export const Gesture = createComponent(GestureDef);
export const Hotkey = createComponent(HotkeyDef);
@@ -83,6 +85,7 @@ export const COMPONENTS = {
Container: ContainerDef,
Controls: ControlsDef,
ErrorDialog: ErrorDialogDef,
FeatureAvailability: FeatureAvailabilityDef,
FullscreenButton: FullscreenButtonDef,
Gesture: GestureDef,
Hotkey: HotkeyDef,
@@ -0,0 +1,6 @@
import { defineComponent } from '../manifest';
import type { FeatureAvailabilityProps } from './props';
export default defineComponent<FeatureAvailabilityProps>({
name: 'FeatureAvailability',
});
@@ -0,0 +1,20 @@
import type { MediaFeatureAvailability } from '../../media/types';
export type FeatureAvailabilityFeature = 'volume';
export type FeatureAvailabilityCondition = MediaFeatureAvailability | readonly MediaFeatureAvailability[];
interface FeatureAvailabilityBaseProps {
is: FeatureAvailabilityFeature;
}
interface FeatureAvailabilityWhenProps extends FeatureAvailabilityBaseProps {
when: FeatureAvailabilityCondition;
except?: never;
}
interface FeatureAvailabilityExceptProps extends FeatureAvailabilityBaseProps {
except: FeatureAvailabilityCondition;
when?: never;
}
export type FeatureAvailabilityProps = FeatureAvailabilityWhenProps | FeatureAvailabilityExceptProps;
+2
View File
@@ -2,6 +2,7 @@
// without creating a skin element. Use this entry when building an ejected
// (light DOM) player layout.
import { MediaContainerElement } from '../../media/container-element';
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
import { LiveButtonElement } from '../../ui/live-button/live-button-element';
@@ -33,6 +34,7 @@ defineTime();
defineMenu();
// Standalone elements.
safeDefine(MediaFeatureElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
safeDefine(LiveButtonElement);
@@ -2,6 +2,7 @@
// elements without creating a skin element. Use this entry when building an
// ejected (light DOM) player layout for live HLS / DASH streams.
import { MediaContainerElement } from '../../media/container-element';
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
import { LiveButtonElement } from '../../ui/live-button/live-button-element';
@@ -28,6 +29,7 @@ defineVolumeSlider();
defineTime();
// Standalone elements.
safeDefine(MediaFeatureElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
safeDefine(LiveButtonElement);
@@ -7,6 +7,7 @@ import { BufferingIndicatorElement } from '../../ui/buffering-indicator/bufferin
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element';
import { CastButtonElement } from '../../ui/cast-button/cast-button-element';
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
@@ -51,6 +52,7 @@ safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(CaptionsRadioGroupElement);
safeDefine(CastButtonElement);
safeDefine(MediaFeatureElement);
safeDefine(FullscreenButtonElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
+10
View File
@@ -0,0 +1,10 @@
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { safeDefine } from '../safe-define';
safeDefine(MediaFeatureElement);
declare global {
interface HTMLElementTagNameMap {
[MediaFeatureElement.tagName]: MediaFeatureElement;
}
}
@@ -8,6 +8,7 @@ import { BufferingIndicatorElement } from '../../ui/buffering-indicator/bufferin
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element';
import { CastButtonElement } from '../../ui/cast-button/cast-button-element';
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
@@ -58,6 +59,7 @@ safeDefine(AirPlayButtonElement);
safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(CastButtonElement);
safeDefine(MediaFeatureElement);
safeDefine(FullscreenButtonElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
+2
View File
@@ -7,6 +7,7 @@ import { BufferingIndicatorElement } from '../../ui/buffering-indicator/bufferin
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element';
import { CastButtonElement } from '../../ui/cast-button/cast-button-element';
import { MediaFeatureElement } from '../../ui/feature/media-feature-element';
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
@@ -56,6 +57,7 @@ safeDefine(AirPlayButtonElement);
safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(CastButtonElement);
safeDefine(MediaFeatureElement);
safeDefine(FullscreenButtonElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
+1
View File
@@ -39,6 +39,7 @@ export { ContextPartElement, type PartContextValue } from './ui/context-part-ele
export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { ErrorDialogElement } from './ui/error-dialog/error-dialog-element';
export { MediaFeatureElement } from './ui/feature/media-feature-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
export { GestureElement } from './ui/gesture/gesture-element';
export { AriaKeyShortcutsController } from './ui/hotkey/aria-key-shortcuts-controller';
@@ -0,0 +1,82 @@
import type { FeatureAvailabilityCondition, FeatureAvailabilityFeature, MediaFeatureAvailability } from '@videojs/core';
import { logMissingFeature, selectVolume } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { isString } from '@videojs/utils/predicate';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
type MediaFeatureElementCondition = FeatureAvailabilityCondition | string;
export class MediaFeatureElement extends MediaElement {
static readonly tagName = 'media-feature';
static override properties = {
is: { type: String },
when: { type: String },
except: { type: String },
} satisfies PropertyDeclarationMap<'is' | 'when' | 'except'>;
is: FeatureAvailabilityFeature | '' = '';
when: MediaFeatureElementCondition | undefined = undefined;
except: MediaFeatureElementCondition | undefined = undefined;
readonly #volumeState = new PlayerController(this, playerContext, selectVolume);
readonly #slot: HTMLSlotElement;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{display:contents}slot[hidden]{display:none}';
this.#slot = document.createElement('slot');
this.#slot.hidden = true;
shadow.append(style, this.#slot);
}
override connectedCallback(): void {
super.connectedCallback();
if (__DEV__ && this.is && !this.#availability) {
logMissingFeature(this.localName, this.is);
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const availability = this.#availability;
if (availability) {
this.dataset.availability = availability;
} else {
delete this.dataset.availability;
}
this.#slot.hidden = !this.#matches(availability);
}
get #availability(): MediaFeatureAvailability | undefined {
if (this.is === 'volume') return this.#volumeState.value?.volumeAvailability;
return undefined;
}
#matches(availability: MediaFeatureAvailability | undefined): boolean {
if (!availability) return false;
if (this.when) return conditionIncludes(this.when, availability);
if (this.except) return !conditionIncludes(this.except, availability);
return false;
}
}
function conditionIncludes(condition: MediaFeatureElementCondition, availability: MediaFeatureAvailability): boolean {
if (isString(condition)) {
return condition.split(/[\s,]+/).includes(availability);
}
return condition.includes(availability);
}
@@ -0,0 +1,159 @@
import type { MediaFeatureAvailability, MediaVolumeState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore, flush } from '@videojs/store';
import { afterEach, describe, expect, it } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MediaFeatureElement } from '../media-feature-element';
interface MediaFeatureTestState extends MediaVolumeState {
setVolumeAvailability(availability: MediaFeatureAvailability): void;
}
function ensureCustomElementDefined(Constructor: CustomElementConstructor & { readonly tagName: string }): void {
const { tagName } = Constructor;
if (!customElements.get(tagName)) {
customElements.define(tagName, Constructor);
}
}
function createDefinedElement<Class extends CustomElementConstructor & { readonly tagName: string }>(
Constructor: Class
): InstanceType<Class> {
ensureCustomElementDefined(Constructor);
return document.createElement(Constructor.tagName) as InstanceType<Class>;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function createMediaFeatureStore(): AnyPlayerStore {
return createStore<unknown>()<MediaFeatureTestState>({
name: 'media-feature',
state: ({ get, set }) => ({
volume: 1,
muted: false,
volumeAvailability: 'available',
setVolume(volume) {
set({ volume });
return volume;
},
toggleMuted() {
const muted = !(get().muted as boolean);
set({ muted });
return muted;
},
setVolumeAvailability(availability) {
set({ volumeAvailability: availability });
},
}),
}) as unknown as AnyPlayerStore;
}
class TestMediaFeaturePlayerProviderElement extends MediaElement {
store = createMediaFeatureStore();
readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store });
override connectedCallback(): void {
super.connectedCallback();
this.#provider.setValue(this.store);
}
setVolumeAvailability(availability: MediaFeatureAvailability): void {
const state = this.store.state as MediaFeatureTestState;
state.setVolumeAvailability(availability);
flush();
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
function defaultSlot(element: MediaFeatureElement): HTMLSlotElement {
return element.shadowRoot!.querySelector('slot')!;
}
defineElement('test-media-feature-player-provider', TestMediaFeaturePlayerProviderElement);
afterEach(() => {
document.body.innerHTML = '';
});
describe('MediaFeatureElement', () => {
it('toggles the default slot for matching when availability', async () => {
const provider = document.createElement(
'test-media-feature-player-provider'
) as TestMediaFeaturePlayerProviderElement;
const mediaFeature = createDefinedElement(MediaFeatureElement);
const content = document.createElement('span');
mediaFeature.is = 'volume';
mediaFeature.when = 'unsupported';
content.textContent = 'Fallback';
mediaFeature.append(content);
document.body.append(provider);
provider.append(mediaFeature);
await mediaFeature.updateComplete;
expect(defaultSlot(mediaFeature).hidden).toBe(true);
expect(mediaFeature.contains(content)).toBe(true);
expect(mediaFeature.dataset.availability).toBe('available');
provider.setVolumeAvailability('unsupported');
await waitForAssertion(() => {
expect(defaultSlot(mediaFeature).hidden).toBe(false);
expect(mediaFeature.dataset.availability).toBe('unsupported');
});
});
it('toggles the default slot for except availability', async () => {
const provider = document.createElement(
'test-media-feature-player-provider'
) as TestMediaFeaturePlayerProviderElement;
const mediaFeature = createDefinedElement(MediaFeatureElement);
mediaFeature.is = 'volume';
mediaFeature.except = 'unsupported';
mediaFeature.textContent = 'Popover';
document.body.append(provider);
provider.append(mediaFeature);
await mediaFeature.updateComplete;
expect(defaultSlot(mediaFeature).hidden).toBe(false);
provider.setVolumeAvailability('unsupported');
await waitForAssertion(() => {
expect(defaultSlot(mediaFeature).hidden).toBe(true);
});
});
});
+1 -1
View File
@@ -39,7 +39,7 @@ export default defineConfig({
}),
tailwind({ mode: 'preserve' }),
code.edit.jsx.element({
when: code.match.jsx.tag('Tooltip.Trigger'),
when: code.match.jsx.tag(/^(Tooltip|Popover)\.Trigger$/),
transform: code.edit.jsx.moveChildToProp('render'),
}),
code.edit.jsx.element({
+1
View File
@@ -44,6 +44,7 @@ export { Controls } from './ui/controls';
export type { ControlsGroupProps } from './ui/controls/controls-group';
export type { ControlsRootProps } from './ui/controls/controls-root';
export { ErrorDialog, type ErrorDialogContextValue, useErrorDialogContext } from './ui/error-dialog';
export { FeatureAvailability, type FeatureAvailabilityProps } from './ui/feature-availability';
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
export { Gesture, type GestureProps, MediaGesture, type MediaGestureProps } from './ui/gesture/gesture';
export { type UseDoubleTapGestureOptions, useDoubleTapGesture } from './ui/gesture/use-doubletap-gesture';
@@ -24,14 +24,18 @@ describe('skins compiler config', () => {
expect(code).toContain('from "@/ui/airplay-button"');
expect(code).toContain('from "@/ui/buffering-indicator"');
expect(code).toContain('from "@/ui/error-dialog"');
expect(code).toContain('from "@/ui/feature-availability"');
expect(code).toContain('from "@/ui/mute-button"');
expect(code).toContain('from "@/ui/overlay"');
expect(code).toContain('from "@/ui/pip-button"');
expect(code).toContain('from "@/ui/play-button"');
expect(code).toContain('from "@/ui/popover"');
expect(code).toContain('from "@/ui/poster"');
expect(code).toContain('from "@/ui/seek-indicator"');
expect(code).toContain('from "@/ui/slider"');
expect(code).toContain('from "@/ui/status-indicator"');
expect(code).toContain('from "@/ui/volume-indicator"');
expect(code).toContain('from "@/ui/volume-slider"');
expect(code).not.toContain('from "@/ui/text"');
expect(code).toContain('from "@/utils/use-render"');
expect(code).toContain('from "@videojs/utils/predicate"');
@@ -114,6 +118,23 @@ describe('skins compiler config', () => {
expect(compactCode).not.toContain('iconContainer');
expect(compactCode).not.toContain('<Text');
expect(compactCode).not.toContain(compact('render={<Button />}'));
expect(compactCode).toContain(compact('function MuteControl()'));
expect(compactCode).toContain(compact('<FeatureAvailability is="volume" when="unsupported">'));
expect(compactCode).toContain(compact('<FeatureAvailability is="volume" except="unsupported">'));
expect(compactCode).toContain(compact('<Popover.Root openOnHover delay={200} closeDelay={100} side="top">'));
expect(compactCode).toContain(compact('<Popover.Trigger render={<MuteControl />} />'));
expect(compactCode).toContain(compact('<MuteButton className={cn(iconButton, muteIcon.button)} type="button">'));
expect(compactCode).toContain(compact('<VolumeOffIcon className={cn(icon, muteIcon.volumeOff)} />'));
expect(compactCode).toContain(compact('<Popover.Popup className={cn(popup.popover, popup.volume)}>'));
expect(compactCode).toContain(
compact('<VolumeSlider.Root orientation="vertical" thumbAlignment="edge" className={slider.root}>')
);
expect(compactCode).toContain(compact('<VolumeSlider.Fill className={cn(slider.fill.base, slider.fill.fill)} />'));
expect(compactCode).toContain(
compact('<VolumeSlider.Thumb className={cn(slider.thumb.base, slider.thumb.persistent)} />')
);
expect(compactCode).not.toContain('functionVolumePopover');
expect(compactCode).not.toContain('VolumePopover');
expect(compactCode).toContain(compact('<TimeSlider.Root className={slider.root}>'));
expect(compactCode).toContain(compact('<TimeSlider.Track className={slider.track}>'));
expect(compactCode).toContain(compact('<TimeSlider.Fill className={cn(slider.fill.base, slider.fill.fill)} />'));
@@ -14,6 +14,7 @@ import {
icon,
iconFlipped,
inputFeedback,
muteIcon,
overlay,
pipIcon,
playIcon,
@@ -53,12 +54,15 @@ import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CastButton } from '@/ui/cast-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FeatureAvailability } from '@/ui/feature-availability';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { Gesture } from '@/ui/gesture';
import { Hotkey } from '@/ui/hotkey';
import { MuteButton } from '@/ui/mute-button';
import { Overlay } from '@/ui/overlay';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { Popover } from '@/ui/popover';
import { Poster } from '@/ui/poster';
import { SeekButton } from '@/ui/seek-button';
import { SeekIndicator } from '@/ui/seek-indicator';
@@ -69,6 +73,7 @@ import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { Tooltip } from '@/ui/tooltip';
import { VolumeIndicator } from '@/ui/volume-indicator';
import { VolumeSlider } from '@/ui/volume-slider';
import { isRenderProp } from '@/utils/use-render';
import type { BaseVideoSkinProps } from '../types';
@@ -85,6 +90,16 @@ export interface DefaultVideoSkinProps extends BaseVideoSkinProps {
children?: ReactNode | undefined;
}
function MuteControl() {
return (
<MuteButton className={cn(iconButton, muteIcon.button)} type="button">
<VolumeOffIcon className={cn(icon, muteIcon.volumeOff)} />
<VolumeLowIcon className={cn(icon, muteIcon.volumeLow)} />
<VolumeHighIcon className={cn(icon, muteIcon.volumeHigh)} />
</MuteButton>
);
}
export function DefaultVideoSkin({ className, children, poster, ...rest }: DefaultVideoSkinProps) {
return (
<Container className={cn(container, className)} {...rest}>
@@ -177,6 +192,23 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau
</Time.Group>
<Controls.Group className={buttonGroupEnd}>
<FeatureAvailability is="volume" when="unsupported">
<MuteControl />
</FeatureAvailability>
<FeatureAvailability is="volume" except="unsupported">
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger render={<MuteControl />} />
<Popover.Popup className={cn(popup.popover, popup.volume)}>
<VolumeSlider.Root orientation="vertical" thumbAlignment="edge" className={slider.root}>
<VolumeSlider.Track className={slider.track}>
<VolumeSlider.Fill className={cn(slider.fill.base, slider.fill.fill)} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={cn(slider.thumb.base, slider.thumb.persistent)} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</FeatureAvailability>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -0,0 +1,44 @@
'use client';
import type {
FeatureAvailabilityProps as CoreFeatureAvailabilityProps,
FeatureAvailabilityCondition,
MediaFeatureAvailability,
} from '@videojs/core';
import { logMissingFeature, selectVolume } from '@videojs/core/dom';
import type { ReactNode } from 'react';
import { usePlayer } from '../../player/context';
type AvailabilitySelector = (state: object) => MediaFeatureAvailability | undefined;
const FEATURE_AVAILABILITY_SELECTORS = {
volume: (state: object) => selectVolume(state)?.volumeAvailability,
} satisfies Record<CoreFeatureAvailabilityProps['is'], AvailabilitySelector>;
export type FeatureAvailabilityProps = CoreFeatureAvailabilityProps & {
children?: ReactNode;
};
function conditionIncludes(condition: FeatureAvailabilityCondition, availability: MediaFeatureAvailability): boolean {
return Array.isArray(condition) ? condition.includes(availability) : condition === availability;
}
export function FeatureAvailability({ is, children, ...props }: FeatureAvailabilityProps): ReactNode {
const availability = usePlayer(FEATURE_AVAILABILITY_SELECTORS[is]);
if (!availability) {
if (__DEV__) logMissingFeature('FeatureAvailability', is);
return null;
}
if ('when' in props) {
return conditionIncludes(props.when, availability) ? children : null;
}
return conditionIncludes(props.except, availability) ? null : children;
}
export namespace FeatureAvailability {
export type Props = FeatureAvailabilityProps;
}
@@ -0,0 +1 @@
export { FeatureAvailability, type FeatureAvailabilityProps } from './feature-availability';
@@ -0,0 +1,77 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPlayerWrapper } from '../../../testing/mocks';
import { FeatureAvailability } from '../feature-availability';
function createWrapper(volumeAvailability: 'available' | 'unsupported') {
return createPlayerWrapper({
volume: 0.5,
muted: false,
volumeAvailability,
setVolume: vi.fn(),
toggleMuted: vi.fn(),
});
}
afterEach(() => {
cleanup();
});
describe('FeatureAvailability', () => {
it('renders children when the availability matches when', () => {
const { Wrapper } = createWrapper('unsupported');
render(
<Wrapper>
<FeatureAvailability is="volume" when="unsupported">
<div data-testid="fallback">Fallback</div>
</FeatureAvailability>
</Wrapper>
);
expect(screen.getByTestId('fallback')).not.toBeNull();
});
it('omits children when the availability does not match when', () => {
const { Wrapper } = createWrapper('available');
render(
<Wrapper>
<FeatureAvailability is="volume" when="unsupported">
<div data-testid="fallback">Fallback</div>
</FeatureAvailability>
</Wrapper>
);
expect(screen.queryByTestId('fallback')).toBeNull();
});
it('renders children when the availability is not excepted', () => {
const { Wrapper } = createWrapper('available');
render(
<Wrapper>
<FeatureAvailability is="volume" except="unsupported">
<div data-testid="popover">Popover</div>
</FeatureAvailability>
</Wrapper>
);
expect(screen.getByTestId('popover')).not.toBeNull();
});
it('omits children when the availability is excepted', () => {
const { Wrapper } = createWrapper('unsupported');
render(
<Wrapper>
<FeatureAvailability is="volume" except="unsupported">
<div data-testid="popover">Popover</div>
</FeatureAvailability>
</Wrapper>
);
expect(screen.queryByTestId('popover')).toBeNull();
});
});
+34
View File
@@ -5,12 +5,15 @@ import {
Container,
Controls,
ErrorDialog,
FeatureAvailability,
FullscreenButton,
Gesture,
Hotkey,
MuteButton,
Overlay,
PiPButton,
PlayButton,
Popover,
Poster,
SeekButton,
SeekIndicator,
@@ -22,6 +25,7 @@ import {
TimeSlider,
Tooltip,
VolumeIndicator,
VolumeSlider,
} from '@videojs/core/components';
import {
AirPlayEnterIcon,
@@ -58,6 +62,7 @@ import {
icon,
iconFlipped,
inputFeedback,
muteIcon,
overlay,
pipIcon,
playIcon,
@@ -78,6 +83,16 @@ export interface DefaultVideoSkinProps {
children?: unknown;
}
function MuteControl() {
return (
<MuteButton className={[iconButton, muteIcon.button]}>
<VolumeOffIcon className={[icon, muteIcon.volumeOff]} />
<VolumeLowIcon className={[icon, muteIcon.volumeLow]} />
<VolumeHighIcon className={[icon, muteIcon.volumeHigh]} />
</MuteButton>
);
}
export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) {
return (
<Container className={[container, className]}>
@@ -162,6 +177,25 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
</Time.Group>
<Controls.Group className={buttonGroupEnd}>
<FeatureAvailability is="volume" when="unsupported">
<MuteControl />
</FeatureAvailability>
<FeatureAvailability is="volume" except="unsupported">
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger>
<MuteControl />
</Popover.Trigger>
<Popover.Popup className={[popup.popover, popup.volume]}>
<VolumeSlider.Root orientation="vertical" thumbAlignment="edge" className={slider.root}>
<VolumeSlider.Track className={slider.track}>
<VolumeSlider.Fill className={[slider.fill.base, slider.fill.fill]} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={[slider.thumb.base, slider.thumb.persistent]} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</FeatureAvailability>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CastButton className={[iconButton, castIcon.button]}>