mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): add buffering indicator component (#527)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user