From 5de514f3ccedc22d0d9d2c94a3525e8caae344ab Mon Sep 17 00:00:00 2001 From: Rahim Alwer Date: Fri, 2 Feb 2024 18:30:37 +1100 Subject: [PATCH] feat(player): new `noScrubGesture` prop on default layout --- .../layouts/default/media-layout.tsx | 7 +++ .../layouts/default/shared-layout.tsx | 4 +- .../player/styles/default/layouts/video.css | 28 +++++----- .../layouts/default/default-layout.ts | 1 + .../src/components/layouts/default/props.ts | 19 ++++--- .../ui/sliders/slider/events-controller.ts | 53 +++++++++++-------- .../ui/sliders/time-slider/time-slider.ts | 11 +++- .../define/layouts/default/shared-layout.ts | 11 +++- 8 files changed, 87 insertions(+), 47 deletions(-) diff --git a/packages/react/src/components/layouts/default/media-layout.tsx b/packages/react/src/components/layouts/default/media-layout.tsx index b342e0812..c0c729b7b 100644 --- a/packages/react/src/components/layouts/default/media-layout.tsx +++ b/packages/react/src/components/layouts/default/media-layout.tsx @@ -72,6 +72,10 @@ export interface DefaultLayoutProps extends PrimitivePropsWithR * enabled by default as it provides a better user experience for touch devices. */ noModal?: boolean; + /** + * Whether to disable scrubbing by touch swiping left or right on the player canvas. + */ + noScrubGesture: boolean; /** * The minimum width of the slider to start displaying slider chapters when available. */ @@ -126,6 +130,7 @@ export function createDefaultMediaLayout({ noGestures = false, noKeyboardActionDisplay = false, noModal = false, + noScrubGesture, seekStep = 10, showMenuDelay, showTooltipDelay = 700, @@ -160,6 +165,7 @@ export function createDefaultMediaLayout({ className={`vds-${type}-layout` + (className ? ` ${className}` : '')} data-match={isMatch ? '' : null} data-size={isSmallLayout ? 'sm' : null} + data-no-scrub-gesture={noScrubGesture ? '' : null} ref={forwardRef} > {canRender && isMatch ? ( @@ -173,6 +179,7 @@ export function createDefaultMediaLayout({ noGestures, noKeyboardActionDisplay, noModal, + noScrubGesture, showMenuDelay, showTooltipDelay, sliderChaptersMinWidth, diff --git a/packages/react/src/components/layouts/default/shared-layout.tsx b/packages/react/src/components/layouts/default/shared-layout.tsx index 8c822983e..6759a9397 100644 --- a/packages/react/src/components/layouts/default/shared-layout.tsx +++ b/packages/react/src/components/layouts/default/shared-layout.tsx @@ -331,7 +331,8 @@ function DefaultTimeSlider() { const [instance, setInstance] = React.useState(null), [width, setWidth] = React.useState(0), $src = useMediaState('currentSrc'), - { thumbnails, sliderChaptersMinWidth, disableTimeSlider, seekStep } = useDefaultLayoutContext(), + { thumbnails, sliderChaptersMinWidth, disableTimeSlider, seekStep, noScrubGesture } = + useDefaultLayoutContext(), label = useDefaultLayoutWord('Seek'), $RemotionSliderThumbnail = useSignal(RemotionSliderThumbnail); @@ -347,6 +348,7 @@ function DefaultTimeSlider() { className="vds-time-slider vds-slider" aria-label={label} disabled={disableTimeSlider} + noSwipeGesture={noScrubGesture} keyStep={seekStep} ref={setInstance} > diff --git a/packages/vidstack/player/styles/default/layouts/video.css b/packages/vidstack/player/styles/default/layouts/video.css index 27b1253bd..c9f1f0477 100644 --- a/packages/vidstack/player/styles/default/layouts/video.css +++ b/packages/vidstack/player/styles/default/layouts/video.css @@ -400,25 +400,25 @@ visibility: hidden; } -:where([data-preview] .vds-video-layout[data-size='sm']) - :where( - .vds-button, - .vds-slider:not(.vds-time-slider), - .vds-time, - .vds-chapter-title, - .vds-time-divider, - .vds-captions, - .vds-live-button - ) { - opacity: 0; -} - :where(.vds-video-layout[data-size='sm'] .vds-time-slider) { transition: transform 0.1s linear; } @media (pointer: coarse) { - :where([data-preview] .vds-video-layout[data-size='sm'] .vds-time-slider) { + :where([data-preview] .vds-video-layout:not([data-no-scrub-gesture])) + :where( + .vds-button, + .vds-slider:not(.vds-time-slider), + .vds-time, + .vds-chapter-title, + .vds-time-divider, + .vds-captions, + .vds-live-button + ) { + opacity: 0; + } + + :where([data-preview] .vds-video-layout:not([data-no-scrub-gesture]) .vds-time-slider) { --track-height: var(--video-sm-slider-focus-track-height, 12px); transform: translateY(-6px); transition: transform 0.1s linear; diff --git a/packages/vidstack/src/components/layouts/default/default-layout.ts b/packages/vidstack/src/components/layouts/default/default-layout.ts index 8cc247e97..cf29c2ce1 100644 --- a/packages/vidstack/src/components/layouts/default/default-layout.ts +++ b/packages/vidstack/src/components/layouts/default/default-layout.ts @@ -40,6 +40,7 @@ export class DefaultLayout extends Component { this.setAttributes({ 'data-match': this._when, 'data-size': () => (this._smallWhen() ? 'sm' : null), + 'data-no-scrub-gesture': this.$props.noScrubGesture, }); const self = this; diff --git a/packages/vidstack/src/components/layouts/default/props.ts b/packages/vidstack/src/components/layouts/default/props.ts index 58b14a9fa..0dd46cef3 100644 --- a/packages/vidstack/src/components/layouts/default/props.ts +++ b/packages/vidstack/src/components/layouts/default/props.ts @@ -3,18 +3,19 @@ import type { ThumbnailSrc } from '../../ui/thumbnails/thumbnail-loader'; import type { DefaultLayoutTranslations } from './translations'; export const defaultLayoutProps: DefaultLayoutProps = { - when: false, - smallWhen: false, - thumbnails: null, customIcons: false, - translations: null, - menuGroup: 'bottom', - noModal: false, - sliderChaptersMinWidth: 325, disableTimeSlider: false, + menuGroup: 'bottom', noGestures: false, noKeyboardActionDisplay: false, + noModal: false, + noScrubGesture: false, seekStep: 10, + sliderChaptersMinWidth: 325, + smallWhen: false, + thumbnails: null, + translations: null, + when: false, }; export interface DefaultLayoutProps { @@ -52,6 +53,10 @@ export interface DefaultLayoutProps { * enabled by default as it provides a better user experience for touch devices. */ noModal: boolean; + /** + * Whether to disable scrubbing by touch swiping left or right on the player canvas. + */ + noScrubGesture: boolean; /** * The minimum width of the slider to start displaying slider chapters when available. */ diff --git a/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts b/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts index 4f0d84f10..4206540bf 100644 --- a/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts +++ b/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts @@ -1,10 +1,9 @@ import throttle from 'just-throttle'; -import { effect, ViewController } from 'maverick.js'; +import { effect, ViewController, type ReadSignal } from 'maverick.js'; import { isNull, isNumber, isUndefined, listenEvent } from 'maverick.js/std'; import type { MediaContext } from '../../../../core'; import { isTouchPinchEvent } from '../../../../utils/dom'; -import { IS_SAFARI } from '../../../../utils/support'; import type { SliderDragEndEvent, SliderDragStartEvent, @@ -31,7 +30,7 @@ const SliderKeyDirection = { } as const; export interface SliderEventDelegate { - _swipeGesture?: boolean; + _swipeGesture?: ReadSignal; _isDisabled(): boolean; _getStep(): number; _getKeyStep(): number; @@ -57,20 +56,30 @@ export class SliderEventsController extends ViewController< protected override onConnect() { effect(this._attachEventListeners.bind(this)); effect(this._attachPointerListeners.bind(this)); - if (this._delegate._swipeGesture) { - const provider = this._media.player.el?.querySelector( - 'media-provider,[data-media-provider]', - ) as HTMLElement | null; - if (provider) { - this._provider = provider; - listenEvent(provider, 'touchstart', this._onTouchStart.bind(this), { - passive: true, - }); - listenEvent(provider, 'touchmove', this._onTouchMove.bind(this), { - passive: false, - }); - } + if (this._delegate._swipeGesture) effect(this._watchSwipeGesture.bind(this)); + } + + private _watchSwipeGesture() { + const { pointer } = this._media.$state; + + if (pointer() !== 'coarse' || !this._delegate._swipeGesture!()) { + this._provider = null; + return; } + + this._provider = this._media.player.el?.querySelector( + 'media-provider,[data-media-provider]', + ) as HTMLElement | null; + + if (!this._provider) return; + + listenEvent(this._provider, 'touchstart', this._onTouchStart.bind(this), { + passive: true, + }); + + listenEvent(this._provider, 'touchmove', this._onTouchMove.bind(this), { + passive: false, + }); } private _provider: HTMLElement | null = null; @@ -88,12 +97,14 @@ export class SliderEventsController extends ViewController< yDiff = touch.clientY - this._touch.clientY, isDragging = this.$state.dragging(); - if (!isDragging && Math.abs(yDiff) > 20) { + if (!isDragging && Math.abs(yDiff) > 5) { return; } if (isDragging) return; + event.preventDefault(); + if (Math.abs(xDiff) > 20) { this._touch = touch; this._touchStartValue = this.$state.value(); @@ -116,11 +127,9 @@ export class SliderEventsController extends ViewController< if (this._delegate._isDisabled() || !this.$state.dragging()) return; listenEvent(document, 'pointerup', this._onDocumentPointerUp.bind(this)); listenEvent(document, 'pointermove', this._onDocumentPointerMove.bind(this)); - if (IS_SAFARI) { - listenEvent(document, 'touchmove', this._onDocumentTouchMove.bind(this), { - passive: false, - }); - } + listenEvent(document, 'touchmove', this._onDocumentTouchMove.bind(this), { + passive: false, + }); } private _onFocus() { diff --git a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts index 11b84b725..50f183d2a 100644 --- a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts +++ b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts @@ -60,6 +60,7 @@ export class TimeSlider extends Component< keyStep: 5, shiftKeyMultiplier: 2, pauseWhileDragging: false, + noSwipeGesture: false, seekingRequestThrottle: 100, }; @@ -71,8 +72,10 @@ export class TimeSlider extends Component< constructor() { super(); + + const { noSwipeGesture } = this.$props; new SliderController({ - _swipeGesture: true, + _swipeGesture: () => !noSwipeGesture(), _getStep: this._getStep.bind(this), _getKeyStep: this._getKeyStep.bind(this), _isDisabled: this._isDisabled.bind(this), @@ -291,6 +294,12 @@ export interface TimeSliderProps extends SliderControllerProps { * The amount of milliseconds to throttle media seeking request events being dispatched. */ seekingRequestThrottle: number; + /** + * Whether touch swiping left or right on the player canvas should activate the time slider. This + * gesture makes it easier for touch users to drag anywhere on the player left or right to + * seek backwards or forwards, without directly interacting with time slider. + */ + noSwipeGesture: boolean; } interface ThrottledSeeking { diff --git a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts index 24b235901..e919839d9 100644 --- a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts @@ -242,8 +242,14 @@ export function DefaultVolumeSlider({ orientation }: { orientation?: SliderOrien export function DefaultTimeSlider() { const $ref = signal(undefined), $width = signal(0), - { thumbnails, translations, sliderChaptersMinWidth, disableTimeSlider, seekStep } = - useDefaultLayoutContext(), + { + thumbnails, + translations, + sliderChaptersMinWidth, + disableTimeSlider, + seekStep, + noScrubGesture, + } = useDefaultLayoutContext(), $label = $i18n(translations, 'Seek'), $isDisabled = $signal(disableTimeSlider), $isChaptersDisabled = $signal(() => $width() < sliderChaptersMinWidth()), @@ -260,6 +266,7 @@ export function DefaultTimeSlider() { aria-label=${$label} key-step=${$signal(seekStep)} ?disabled=${$isDisabled} + ?no-swipe-gesture=${$signal(noScrubGesture)} ${ref($ref.set)} >