diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index ccea9b90..f051a080 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts new file mode 100644 index 00000000..3935eceb --- /dev/null +++ b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts @@ -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 = { + delay: 500, + }; + + readonly state = createState({ visible: false }); + + #props = { ...BufferingIndicatorCore.defaultProps }; + #timer: ReturnType | 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; +} diff --git a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-data-attrs.ts b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-data-attrs.ts new file mode 100644 index 00000000..eb0444cd --- /dev/null +++ b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts b/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts new file mode 100644 index 00000000..cd6daf13 --- /dev/null +++ b/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/packages/html/src/define/ui/buffering-indicator.ts b/packages/html/src/define/ui/buffering-indicator.ts new file mode 100644 index 00000000..f0e0faee --- /dev/null +++ b/packages/html/src/define/ui/buffering-indicator.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index bff75add..96b50c0d 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts b/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts new file mode 100644 index 00000000..ddf10b18 --- /dev/null +++ b/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts @@ -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; + + 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); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index cb7a5de0..f101fa07 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/buffering-indicator/buffering-indicator.tsx b/packages/react/src/ui/buffering-indicator/buffering-indicator.tsx new file mode 100644 index 00000000..c826baa2 --- /dev/null +++ b/packages/react/src/ui/buffering-indicator/buffering-indicator.tsx @@ -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 + * + * + * + * + * ( + *
{state.visible && }
+ * )} + * /> + * ``` + */ +export const BufferingIndicator = forwardRef(function BufferingIndicator( + componentProps: BufferingIndicatorProps, + forwardedRef: ForwardedRef +) { + 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; +}