From 131e176ddef6e94dc04d2176fb90276db6744706 Mon Sep 17 00:00:00 2001 From: Renzo Delfino <75499398+R-Delfino95@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:55:59 -0300 Subject: [PATCH] feat(packages): add pauseOnDrag to time slider (#1596) Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com> --- .../tests/time-slider-core.test.ts | 75 ++++++- .../core/ui/time-slider/time-slider-core.ts | 35 ++- .../tests/time-slider-element.test.ts | 11 + .../src/ui/time-slider/time-slider-element.ts | 15 ++ .../ui/time-slider/tests/time-slider.test.tsx | 209 +++++++++++++++--- .../src/ui/time-slider/time-slider-root.tsx | 25 ++- 6 files changed, 328 insertions(+), 42 deletions(-) diff --git a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts index a8e91a41..9cb9ed22 100644 --- a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts +++ b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts @@ -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 { + 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); + }); + }); }); diff --git a/packages/core/src/core/ui/time-slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts index 2620180a..d6db0f14 100644 --- a/packages/core/src/core/ui/time-slider/time-slider-core.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts @@ -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 { @@ -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); diff --git a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts index 64aa8977..5d912aac 100644 --- a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts +++ b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts @@ -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 () => { diff --git a/packages/html/src/ui/time-slider/time-slider-element.ts b/packages/html/src/ui/time-slider/time-slider-element.ts index 069a64e3..c194c14d 100644 --- a/packages/html/src/ui/time-slider/time-slider-element.ts +++ b/packages/html/src/ui/time-slider/time-slider-element.ts @@ -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>; 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); diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx index d16cdfba..2ffad02d 100644 --- a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx +++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx @@ -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: (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: (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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + capturedSliderOptions.current.onDragStart?.(); + expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1); + + rerender( + + + + ); + + 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( + + + + ); + + capturedSliderOptions.current.onDragStart?.(); + expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1); + + unmount(); + expect(mockPlaybackState.play).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/ui/time-slider/time-slider-root.tsx b/packages/react/src/ui/time-slider/time-slider-root.tsx index bbafe95d..0d542806 100644 --- a/packages/react/src/ui/time-slider/time-slider-root.tsx +++ b/packages/react/src/ui/time-slider/time-slider-root.tsx @@ -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( 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( 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) {