feat(core): add buffering indicator component (#527)

This commit is contained in:
rahim
2026-02-14 00:28:05 +11:00
committed by GitHub
parent c733077d32
commit aa0fb7ca70
9 changed files with 440 additions and 0 deletions
+2
View File
@@ -1,4 +1,6 @@
export * from './media/state';
export * from './ui/buffering-indicator/buffering-indicator-core';
export * from './ui/buffering-indicator/buffering-indicator-data-attrs';
export * from './ui/controls/controls-core';
export * from './ui/controls/controls-data-attrs';
export * from './ui/fullscreen-button/fullscreen-button-core';
@@ -0,0 +1,53 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPlaybackState } from '../../media/state';
export interface BufferingIndicatorProps {
/** Delay in milliseconds before the indicator becomes visible. */
delay?: number | undefined;
}
export interface BufferingIndicatorState {
/** Whether the indicator should be visible. True after the delay elapses while media is waiting and not paused. */
visible: boolean;
}
export class BufferingIndicatorCore {
static readonly defaultProps: NonNullableObject<BufferingIndicatorProps> = {
delay: 500,
};
readonly state = createState<BufferingIndicatorState>({ visible: false });
#props = { ...BufferingIndicatorCore.defaultProps };
#timer: ReturnType<typeof setTimeout> | null = null;
setProps(props: BufferingIndicatorProps): void {
this.#props = defaults(props, BufferingIndicatorCore.defaultProps);
}
update(media: MediaPlaybackState): void {
const buffering = media.waiting && !media.paused;
if (buffering && !this.state.current.visible && !this.#timer) {
this.#timer = setTimeout(() => {
this.#timer = null;
this.state.patch({ visible: true });
}, this.#props.delay);
} else if (!buffering) {
if (this.#timer !== null) {
clearTimeout(this.#timer);
this.#timer = null;
}
this.state.patch({ visible: false });
}
}
}
export namespace BufferingIndicatorCore {
export type Props = BufferingIndicatorProps;
export type State = BufferingIndicatorState;
}
@@ -0,0 +1,7 @@
import type { StateAttrMap } from '../types';
import type { BufferingIndicatorState } from './buffering-indicator-core';
export const BufferingIndicatorDataAttrs = {
/** Present when the buffering indicator is visible (after delay). */
visible: 'data-visible',
} as const satisfies StateAttrMap<BufferingIndicatorState>;
@@ -0,0 +1,237 @@
import { flush } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MediaPlaybackState } from '../../../media/state';
import { BufferingIndicatorCore } from '../buffering-indicator-core';
function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPlaybackState {
return {
paused: true,
ended: false,
started: false,
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
...overrides,
};
}
describe('BufferingIndicatorCore', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('update', () => {
it('visible is false when not waiting', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: false, paused: false }));
expect(core.state.current.visible).toBe(false);
});
it('visible is false when waiting but paused', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: true }));
expect(core.state.current.visible).toBe(false);
});
it('visible is false immediately when waiting and not paused (before delay)', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
expect(core.state.current.visible).toBe(false);
});
it('visible becomes true after default delay elapses', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(true);
});
it('visible stays false if waiting ends before delay', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(300);
core.update(createMediaState({ waiting: false, paused: false }));
expect(core.state.current.visible).toBe(false);
// Even after more time, still not visible
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(false);
});
it('resets timer when waiting toggles off and on within delay', () => {
const core = new BufferingIndicatorCore();
const waiting = createMediaState({ waiting: true, paused: false });
const notWaiting = createMediaState({ waiting: false, paused: false });
core.update(waiting);
vi.advanceTimersByTime(300);
// Stop waiting (cancels timer)
core.update(notWaiting);
// Start waiting again (new timer)
core.update(waiting);
vi.advanceTimersByTime(300);
// Only 300ms into the new timer, not yet visible
expect(core.state.current.visible).toBe(false);
// Full 500ms from second start
vi.advanceTimersByTime(200);
expect(core.state.current.visible).toBe(true);
});
it('immediately hides when waiting ends while visible', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(true);
core.update(createMediaState({ waiting: false, paused: false }));
expect(core.state.current.visible).toBe(false);
});
it('immediately hides when paused while visible', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(true);
core.update(createMediaState({ waiting: true, paused: true }));
expect(core.state.current.visible).toBe(false);
});
it('is idempotent — repeated calls with same state do not restart timer', () => {
const core = new BufferingIndicatorCore();
const media = createMediaState({ waiting: true, paused: false });
core.update(media);
vi.advanceTimersByTime(300);
// Same state again — should NOT restart timer
core.update(media);
vi.advanceTimersByTime(200);
// 500ms total from first call — timer should fire
expect(core.state.current.visible).toBe(true);
});
});
describe('custom delay', () => {
it('respects a custom delay value', () => {
const core = new BufferingIndicatorCore();
core.setProps({ delay: 1000 });
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(false);
vi.advanceTimersByTime(500);
expect(core.state.current.visible).toBe(true);
});
it('respects delay: 0 for immediate visibility', () => {
const core = new BufferingIndicatorCore();
core.setProps({ delay: 0 });
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(0);
expect(core.state.current.visible).toBe(true);
});
it('does not apply new delay to an already running timer', () => {
const core = new BufferingIndicatorCore();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(300);
core.setProps({ delay: 1000 });
vi.advanceTimersByTime(200);
// Original 500ms timer fires
expect(core.state.current.visible).toBe(true);
});
});
describe('state.subscribe', () => {
it('notifies subscribers when visible becomes true', () => {
const core = new BufferingIndicatorCore();
const callback = vi.fn();
core.state.subscribe(callback);
core.update(createMediaState({ waiting: true, paused: false }));
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
flush();
expect(callback).toHaveBeenCalledOnce();
});
it('notifies subscribers when visible becomes false', () => {
const core = new BufferingIndicatorCore();
const callback = vi.fn();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
flush();
core.state.subscribe(callback);
core.update(createMediaState({ waiting: false, paused: false }));
flush();
expect(callback).toHaveBeenCalledOnce();
});
it('does not notify if waiting ends before delay', () => {
const core = new BufferingIndicatorCore();
const callback = vi.fn();
core.state.subscribe(callback);
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(300);
core.update(createMediaState({ waiting: false, paused: false }));
vi.advanceTimersByTime(500);
flush();
expect(callback).not.toHaveBeenCalled();
});
it('supports unsubscribe', () => {
const core = new BufferingIndicatorCore();
const callback = vi.fn();
const unsubscribe = core.state.subscribe(callback);
unsubscribe();
core.update(createMediaState({ waiting: true, paused: false }));
vi.advanceTimersByTime(500);
flush();
expect(callback).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,9 @@
import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element';
customElements.define(BufferingIndicatorElement.tagName, BufferingIndicatorElement);
declare global {
interface HTMLElementTagNameMap {
[BufferingIndicatorElement.tagName]: BufferingIndicatorElement;
}
}
+1
View File
@@ -14,6 +14,7 @@ export * from './store/container-mixin';
export * from './store/provider-mixin';
export * from './store/types';
// UI Components
export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element';
export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
@@ -0,0 +1,58 @@
import { BufferingIndicatorCore, BufferingIndicatorDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectPlayback } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class BufferingIndicatorElement extends MediaElement {
static readonly tagName = 'media-buffering-indicator';
static override properties = {
delay: { type: Number },
} satisfies PropertyDeclarationMap<keyof BufferingIndicatorCore.Props>;
delay = BufferingIndicatorCore.defaultProps.delay;
readonly #core = new BufferingIndicatorCore();
readonly #state = new PlayerController(this, playerContext, selectPlayback);
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
this.#core.state.subscribe(() => this.requestUpdate(), {
signal: this.#disconnect.signal,
});
if (__DEV__ && !this.#state.value) {
logMissingFeature(BufferingIndicatorElement.tagName, 'playback');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#state.value;
if (!media) return;
this.#core.update(media);
applyStateDataAttrs(this, this.#core.state.current, BufferingIndicatorDataAttrs);
}
}
+1
View File
@@ -30,6 +30,7 @@ export {
} from './player/create-player';
// UI
export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator';
export { Controls } from './ui/controls';
export type { ControlsGroupProps } from './ui/controls/controls-group';
export type { ControlsRootProps } from './ui/controls/controls-root';
@@ -0,0 +1,72 @@
'use client';
import { BufferingIndicatorCore, BufferingIndicatorDataAttrs } from '@videojs/core';
import { logMissingFeature, selectPlayback } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState, useSyncExternalStore } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
export interface BufferingIndicatorProps
extends UIComponentProps<'div', BufferingIndicatorCore.State>,
BufferingIndicatorCore.Props {}
/**
* Displays a buffering indicator when media is waiting for data.
*
* Visibility is delayed (default 500ms) to avoid flashing on quick buffers.
*
* @example
* ```tsx
* <BufferingIndicator />
*
* <BufferingIndicator delay={1000} />
*
* <BufferingIndicator
* render={(props, state) => (
* <div {...props}>{state.visible && <Spinner />}</div>
* )}
* />
* ```
*/
export const BufferingIndicator = forwardRef(function BufferingIndicator(
componentProps: BufferingIndicatorProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, delay, ...elementProps } = componentProps;
const playback = usePlayer(selectPlayback);
const [core] = useState(() => new BufferingIndicatorCore());
core.setProps({ delay });
if (playback) core.update(playback);
const state = useSyncExternalStore(
(cb) => core.state.subscribe(cb),
() => core.state.current
);
if (!playback) {
if (__DEV__) logMissingFeature('BufferingIndicator', 'playback');
return null;
}
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: BufferingIndicatorDataAttrs,
ref: [forwardedRef],
props: [elementProps],
}
);
});
export namespace BufferingIndicator {
export type Props = BufferingIndicatorProps;
export type State = BufferingIndicatorCore.State;
}