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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user