diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index c4cda2d9..a3df99a0 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/components.generated.ts b/packages/core/src/core/ui/components.generated.ts index 1fb01a6b..a143047d 100644 --- a/packages/core/src/core/ui/components.generated.ts +++ b/packages/core/src/core/ui/components.generated.ts @@ -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, diff --git a/packages/core/src/core/ui/feature-availability/feature-availability-component.ts b/packages/core/src/core/ui/feature-availability/feature-availability-component.ts new file mode 100644 index 00000000..4875a96c --- /dev/null +++ b/packages/core/src/core/ui/feature-availability/feature-availability-component.ts @@ -0,0 +1,6 @@ +import { defineComponent } from '../manifest'; +import type { FeatureAvailabilityProps } from './props'; + +export default defineComponent({ + name: 'FeatureAvailability', +}); diff --git a/packages/core/src/core/ui/feature-availability/props.ts b/packages/core/src/core/ui/feature-availability/props.ts new file mode 100644 index 00000000..879a982c --- /dev/null +++ b/packages/core/src/core/ui/feature-availability/props.ts @@ -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; diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index 2e645c14..ac833e23 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -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); diff --git a/packages/html/src/define/live-audio/ui.ts b/packages/html/src/define/live-audio/ui.ts index 6854ffcd..1f573823 100644 --- a/packages/html/src/define/live-audio/ui.ts +++ b/packages/html/src/define/live-audio/ui.ts @@ -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); diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index dad12287..196c2459 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -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); diff --git a/packages/html/src/define/ui/feature.ts b/packages/html/src/define/ui/feature.ts new file mode 100644 index 00000000..df17541f --- /dev/null +++ b/packages/html/src/define/ui/feature.ts @@ -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; + } +} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index c211d49b..e0bc8a88 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -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); diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 7a4ddece..78782d38 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -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); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index c09bb0dc..66161137 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/feature/media-feature-element.ts b/packages/html/src/ui/feature/media-feature-element.ts new file mode 100644 index 00000000..85dadc71 --- /dev/null +++ b/packages/html/src/ui/feature/media-feature-element.ts @@ -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); +} diff --git a/packages/html/src/ui/feature/tests/media-feature-element.test.ts b/packages/html/src/ui/feature/tests/media-feature-element.test.ts new file mode 100644 index 00000000..019b26b0 --- /dev/null +++ b/packages/html/src/ui/feature/tests/media-feature-element.test.ts @@ -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( + Constructor: Class +): InstanceType { + ensureCustomElementDefined(Constructor); + return document.createElement(Constructor.tagName) as InstanceType; +} + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function createMediaFeatureStore(): AnyPlayerStore { + return createStore()({ + 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 { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + 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); + }); + }); +}); diff --git a/packages/react/skins.compiler.config.ts b/packages/react/skins.compiler.config.ts index adb9c7c4..c89513ef 100644 --- a/packages/react/skins.compiler.config.ts +++ b/packages/react/skins.compiler.config.ts @@ -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({ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 52474df4..869f45cb 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/presets/tests/skins-compiler-config.test.ts b/packages/react/src/presets/tests/skins-compiler-config.test.ts index 185a1c07..e1035d50 100644 --- a/packages/react/src/presets/tests/skins-compiler-config.test.ts +++ b/packages/react/src/presets/tests/skins-compiler-config.test.ts @@ -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('}')); + expect(compactCode).toContain(compact('function MuteControl()')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('} />')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain( + compact('') + ); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain( + compact('') + ); + expect(compactCode).not.toContain('functionVolumePopover'); + expect(compactCode).not.toContain('VolumePopover'); expect(compactCode).toContain(compact('')); expect(compactCode).toContain(compact('')); expect(compactCode).toContain(compact('')); diff --git a/packages/react/src/presets/video/default-video.generated.tailwind.tsx b/packages/react/src/presets/video/default-video.generated.tailwind.tsx index a0ecb6aa..771dc3da 100644 --- a/packages/react/src/presets/video/default-video.generated.tailwind.tsx +++ b/packages/react/src/presets/video/default-video.generated.tailwind.tsx @@ -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 ( + + + + + + ); +} + export function DefaultVideoSkin({ className, children, poster, ...rest }: DefaultVideoSkinProps) { return ( @@ -177,6 +192,23 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau + + + + + + } /> + + + + + + + + + + + MediaFeatureAvailability | undefined; + +const FEATURE_AVAILABILITY_SELECTORS = { + volume: (state: object) => selectVolume(state)?.volumeAvailability, +} satisfies Record; + +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; +} diff --git a/packages/react/src/ui/feature-availability/index.ts b/packages/react/src/ui/feature-availability/index.ts new file mode 100644 index 00000000..521865e7 --- /dev/null +++ b/packages/react/src/ui/feature-availability/index.ts @@ -0,0 +1 @@ +export { FeatureAvailability, type FeatureAvailabilityProps } from './feature-availability'; diff --git a/packages/react/src/ui/feature-availability/tests/feature-availability.test.tsx b/packages/react/src/ui/feature-availability/tests/feature-availability.test.tsx new file mode 100644 index 00000000..9abad34e --- /dev/null +++ b/packages/react/src/ui/feature-availability/tests/feature-availability.test.tsx @@ -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( + + +
Fallback
+
+
+ ); + + expect(screen.getByTestId('fallback')).not.toBeNull(); + }); + + it('omits children when the availability does not match when', () => { + const { Wrapper } = createWrapper('available'); + + render( + + +
Fallback
+
+
+ ); + + expect(screen.queryByTestId('fallback')).toBeNull(); + }); + + it('renders children when the availability is not excepted', () => { + const { Wrapper } = createWrapper('available'); + + render( + + +
Popover
+
+
+ ); + + expect(screen.getByTestId('popover')).not.toBeNull(); + }); + + it('omits children when the availability is excepted', () => { + const { Wrapper } = createWrapper('unsupported'); + + render( + + +
Popover
+
+
+ ); + + expect(screen.queryByTestId('popover')).toBeNull(); + }); +}); diff --git a/packages/skins/src/default/video.skin.tsx b/packages/skins/src/default/video.skin.tsx index 91b2c247..abbccce2 100644 --- a/packages/skins/src/default/video.skin.tsx +++ b/packages/skins/src/default/video.skin.tsx @@ -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 ( + + + + + + ); +} + export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) { return ( @@ -162,6 +177,25 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) + + + + + + + + + + + + + + + + + + +