mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(packages): add pauseOnDrag to time slider (#1596)
Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
This commit is contained in:
co-authored by
shiminshen
parent
058fb8cfdd
commit
131e176dde
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user