feat(packages): add pauseOnDrag to time slider (#1596)

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
This commit is contained in:
Renzo Delfino
2026-06-30 13:55:59 -07:00
committed by GitHub
co-authored by shiminshen
parent 058fb8cfdd
commit 131e176dde
6 changed files with 328 additions and 42 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaBufferState, MediaTimeState } from '../../../media/state';
import type { MediaBufferState, MediaPlaybackState, MediaTimeState } from '../../../media/state';
import type { SliderInput } from '../../slider/slider-core';
import { TimeSliderCore } from '../time-slider-core';
@@ -43,6 +43,7 @@ describe('TimeSliderCore', () => {
min: 0,
max: 100,
changeThrottle: 100,
pauseOnDrag: false,
});
});
});
@@ -242,4 +243,76 @@ describe('TimeSliderCore', () => {
expect(state.orientation).toBe('vertical');
});
});
describe('startDrag/endDrag', () => {
function createPlaybackState(overrides: Partial<MediaPlaybackState> = {}): MediaPlaybackState {
return {
paused: false,
ended: false,
started: true,
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
togglePaused: vi.fn(() => false),
...overrides,
};
}
it('does nothing when pauseOnDrag is false (default)', () => {
const core = new TimeSliderCore();
const playback = createPlaybackState();
core.startDrag(playback);
expect(playback.pause).not.toHaveBeenCalled();
core.endDrag(playback);
expect(playback.play).not.toHaveBeenCalled();
});
it('pauses on startDrag and resumes on endDrag when playing', () => {
const core = new TimeSliderCore({ pauseOnDrag: true });
const playback = createPlaybackState();
core.startDrag(playback);
expect(playback.pause).toHaveBeenCalledTimes(1);
core.endDrag(playback);
expect(playback.play).toHaveBeenCalledTimes(1);
});
it('does not resume on endDrag when playback was already paused', () => {
const core = new TimeSliderCore({ pauseOnDrag: true });
const playback = createPlaybackState({ paused: true });
core.startDrag(playback);
expect(playback.pause).not.toHaveBeenCalled();
core.endDrag(playback);
expect(playback.play).not.toHaveBeenCalled();
});
it('resumes on endDrag even if pauseOnDrag is turned off mid-drag', () => {
const core = new TimeSliderCore({ pauseOnDrag: true });
const playback = createPlaybackState();
core.startDrag(playback);
expect(playback.pause).toHaveBeenCalledTimes(1);
core.setProps({ pauseOnDrag: false });
core.endDrag(playback);
expect(playback.play).toHaveBeenCalledTimes(1);
});
it('does not resume on a second endDrag', () => {
const core = new TimeSliderCore({ pauseOnDrag: true });
const playback = createPlaybackState();
core.startDrag(playback);
core.endDrag(playback);
core.endDrag(playback);
expect(playback.play).toHaveBeenCalledTimes(1);
});
});
});
@@ -2,7 +2,7 @@ import { defaults } from '@videojs/utils/object';
import { formatTimeAsPhrase } from '@videojs/utils/time';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaBufferState, MediaTimeState } from '../../media/state';
import type { MediaBufferState, MediaPlaybackState, MediaTimeState } from '../../media/state';
import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core';
export interface TimeSliderProps extends SliderProps {
@@ -14,6 +14,11 @@ export interface TimeSliderProps extends SliderProps {
max?: number | undefined;
/** Leading+trailing throttle (ms) for `onValueChange` during drag. */
changeThrottle?: number | undefined;
/**
* When true, pause playback while the user is dragging the thumb,
* resuming on release if it was playing before.
*/
pauseOnDrag?: boolean | undefined;
}
export interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
@@ -27,10 +32,12 @@ export class TimeSliderCore extends SliderCore {
...SliderCore.defaultProps,
label: 'Seek',
changeThrottle: 100,
pauseOnDrag: false,
};
#props = { ...TimeSliderCore.defaultProps };
#media: (MediaTimeState & MediaBufferState) | null = null;
#wasPlayingBeforeDrag = false;
constructor(props?: TimeSliderProps) {
super();
@@ -72,6 +79,32 @@ export class TimeSliderCore extends SliderCore {
return super.getLabel(state) || 'Seek';
}
/**
* Pause playback when a drag begins if `pauseOnDrag` is enabled, remembering
* whether media was playing so `endDrag` can resume it.
*/
startDrag(playback: MediaPlaybackState | null | undefined): void {
this.#wasPlayingBeforeDrag = false;
if (this.#props.pauseOnDrag && playback && !playback.paused) {
this.#wasPlayingBeforeDrag = true;
playback.pause();
}
}
/**
* Resume playback if `startDrag` paused it. Resume depends only on the intent
* captured at drag start, so it survives `pauseOnDrag` being toggled mid-drag.
* Safe to call on teardown — a no-op unless a drag paused playback.
*/
endDrag(playback: MediaPlaybackState | null | undefined): void {
if (this.#wasPlayingBeforeDrag) {
playback?.play().catch(() => {
// Resume play() can reject (autoplay policy, etc.) — surface via existing error feature.
});
}
this.#wasPlayingBeforeDrag = false;
}
override getAttrs(state: TimeSliderState) {
const base = super.getAttrs(state);
@@ -34,6 +34,17 @@ describe('TimeSliderElement', () => {
expect(slider.orientation).toBe('horizontal');
expect(slider.disabled).toBe(false);
expect(slider.thumbAlignment).toBe('center');
expect(slider.pauseOnDrag).toBe(false);
});
it('reflects pause-on-drag attribute to property', async () => {
const slider = createElement(TimeSliderElement);
slider.setAttribute('pause-on-drag', '');
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.pauseOnDrag).toBe(true);
});
it('binds rootProps pointer events on connect', async () => {
@@ -7,6 +7,7 @@ import {
logMissingFeature,
type SliderApi,
selectBuffer,
selectPlayback,
selectTime,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
@@ -30,6 +31,7 @@ export class TimeSliderElement extends MediaElement {
orientation: { type: String },
disabled: { type: Boolean },
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
pauseOnDrag: { type: Boolean, attribute: 'pause-on-drag' },
} satisfies PropertyDeclarationMap<Exclude<keyof TimeSliderCore.Props, 'value' | 'min' | 'max'>>;
label = TimeSliderCore.defaultProps.label;
@@ -39,11 +41,13 @@ export class TimeSliderElement extends MediaElement {
orientation = TimeSliderCore.defaultProps.orientation;
disabled = TimeSliderCore.defaultProps.disabled;
thumbAlignment = TimeSliderCore.defaultProps.thumbAlignment;
pauseOnDrag = TimeSliderCore.defaultProps.pauseOnDrag;
readonly #core = new TimeSliderCore();
readonly #provider = new ContextProvider(this, { context: sliderContext });
readonly #timeState = new PlayerController(this, playerContext, selectTime);
readonly #bufferState = new PlayerController(this, playerContext, selectBuffer);
readonly #playbackState = new PlayerController(this, playerContext, selectPlayback);
#slider: SliderApi | null = null;
#disconnect: AbortController | null = null;
@@ -74,9 +78,11 @@ export class TimeSliderElement extends MediaElement {
},
changeThrottle: this.changeThrottle,
onDragStart: () => {
this.#core.startDrag(this.#playbackState.value);
this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true }));
},
onDragEnd: () => {
this.#core.endDrag(this.#playbackState.value);
this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true }));
},
adjustPercent: (raw, thumbSize, trackSize) => this.#core.adjustPercentForAlignment(raw, thumbSize, trackSize),
@@ -93,16 +99,25 @@ export class TimeSliderElement extends MediaElement {
}
override disconnectedCallback(): void {
this.#resumeIfDragPaused();
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
override destroyCallback(): void {
this.#resumeIfDragPaused();
this.#slider?.destroy();
super.destroyCallback();
}
// createSlider's destroy() does not fire onDragEnd, so a teardown mid-drag
// would leave playback paused. Called from both disconnect and destroy paths
// before super so the PlayerController is still attached.
#resumeIfDragPaused(): void {
this.#core.endDrag(this.#playbackState.value);
}
protected override willUpdate(_changed: PropertyValues): void {
super.willUpdate(_changed);
this.#core.setProps(this);
@@ -12,42 +12,59 @@ import { TimeSliderRoot } from '../time-slider-root';
// --- Hoisted mock data (available inside vi.mock factories) ---
const { mockSliderApi, mockTimeState, mockBufferState } = vi.hoisted(() => ({
mockSliderApi: () => ({
input: {
current: {
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
},
subscribe: vi.fn(() => vi.fn()),
const { mockSliderApi, mockTimeState, mockBufferState, mockPlaybackState, capturedSliderOptions } = vi.hoisted(() => {
const capturedSliderOptions: { current: { onDragStart?: () => void; onDragEnd?: () => void } } = {
current: {},
};
return {
mockSliderApi: (options: { onDragStart?: () => void; onDragEnd?: () => void }) => {
capturedSliderOptions.current = options;
return {
input: {
current: {
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
},
subscribe: vi.fn(() => vi.fn()),
},
rootProps: {
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerLeave: vi.fn(),
},
thumbProps: {
onKeyDown: vi.fn(),
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
};
},
rootProps: {
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerLeave: vi.fn(),
mockTimeState: {
currentTime: 30,
duration: 120,
seeking: false,
seek: vi.fn(),
},
thumbProps: {
onKeyDown: vi.fn(),
onFocus: vi.fn(),
onBlur: vi.fn(),
mockBufferState: {
buffered: [[0, 60]] as [number, number][],
seekable: [[0, 120]] as [number, number][],
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
mockTimeState: {
currentTime: 30,
duration: 120,
seeking: false,
seek: vi.fn(),
},
mockBufferState: {
buffered: [[0, 60]] as [number, number][],
seekable: [[0, 120]] as [number, number][],
},
}));
mockPlaybackState: {
paused: false,
ended: false,
started: true,
waiting: false,
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
},
capturedSliderOptions,
};
});
// --- Module mocks ---
@@ -61,13 +78,13 @@ vi.mock('@videojs/store/react', () => ({
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
if (!selector) return _store;
try {
const result = selector({ time: mockTimeState, buffer: mockBufferState });
const result = selector({ time: mockTimeState, buffer: mockBufferState, playback: mockPlaybackState });
if (result !== undefined) return result;
} catch {
// fall through
}
try {
return selector({ ...mockTimeState, ...mockBufferState });
return selector({ ...mockTimeState, ...mockBufferState, ...mockPlaybackState });
} catch {
return undefined;
}
@@ -195,3 +212,125 @@ describe('TimeSlider compound', () => {
expect(output?.textContent).toBeTruthy();
});
});
describe('TimeSliderRoot pauseOnDrag', () => {
it('does nothing when pauseOnDrag is false (default)', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).not.toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).not.toHaveBeenCalled();
});
it('pauses on drag-start and resumes on drag-end when playing', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
it('does not resume on drag-end when player was already paused', () => {
mockPlaybackState.paused = true;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).not.toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).not.toHaveBeenCalled();
});
it('forwards user-provided onDragStart and onDragEnd', () => {
mockPlaybackState.paused = false;
const onDragStart = vi.fn();
const onDragEnd = vi.fn();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag onDragStart={onDragStart} onDragEnd={onDragEnd} />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(onDragStart).toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(onDragEnd).toHaveBeenCalled();
});
it('resumes on drag-end even if pauseOnDrag is turned off mid-drag', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
const { rerender } = render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
rerender(
<Wrapper>
<TimeSliderRoot pauseOnDrag={false} />
</Wrapper>
);
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
it('resumes on unmount if a drag paused playback', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
const { unmount } = render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
unmount();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
});
@@ -1,9 +1,9 @@
'use client';
import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core';
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectTime } from '@videojs/core/dom';
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectPlayback, selectTime } from '@videojs/core/dom';
import { formatTime } from '@videojs/utils/time';
import { forwardRef, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
@@ -34,17 +34,26 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
thumbAlignment,
onDragStart,
onDragEnd,
pauseOnDrag,
...elementProps
} = componentProps;
const time = usePlayer(selectTime);
const buffer = usePlayer(selectBuffer);
const playback = usePlayer(selectPlayback);
const [core] = useState(() => new TimeSliderCore());
core.setProps({ label, step, largeStep, orientation, disabled, thumbAlignment });
core.setProps({ label, step, largeStep, orientation, disabled, thumbAlignment, pauseOnDrag });
// Keep a ref to the latest media state for callbacks that fire outside the render cycle.
const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null);
const playbackRef = useLatestRef(playback);
// Resume playback if the slider unmounts mid-drag — createSlider's destroy()
// does not fire onDragEnd, so without this the player would stay paused.
useEffect(() => {
return () => core.endDrag(playbackRef.current);
}, [core]);
const duration = time?.duration ?? 0;
@@ -79,8 +88,14 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
const media = mediaRef.current;
if (media) media.seek(core.rawValueFromPercent(percent));
},
onDragStart,
onDragEnd,
onDragStart: () => {
core.startDrag(playbackRef.current);
onDragStart?.();
},
onDragEnd: () => {
core.endDrag(playbackRef.current);
onDragEnd?.();
},
});
if (!time) {