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);