mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add feature availability primitive
This commit is contained in:
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]}>
|
||||
|
||||
Reference in New Issue
Block a user