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
+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);
});
});
});