From b5a3f551f046d5107a24ce2e263159058a7cecbf Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Mon, 22 Dec 2025 09:27:27 +1100 Subject: [PATCH] feat(core): add user activity logic (#278) --- cspell.json | 5 +- examples/html/src/frosted-eject.css | 36 ++--- examples/html/src/minimal-eject.css | 53 +++---- .../src/skins/ejected/frosted/frosted.css | 38 ++--- .../src/skins/ejected/minimal/minimal.css | 53 +++---- .../html/src/media/media-container.ts | 150 +++++++++++++++++- .../html/src/skins/frosted/styles.css | 39 ++--- .../html/src/skins/minimal/styles.css | 51 +++--- .../react/src/components/FullscreenButton.tsx | 1 + .../react/src/components/MediaContainer.tsx | 116 +++++++++++++- .../react/src/components/MuteButton.tsx | 1 + .../react/src/components/PlayButton.tsx | 1 + .../react/src/skins/frosted/styles.ts | 29 ++-- .../react/src/skins/minimal/styles.ts | 32 ++-- 14 files changed, 406 insertions(+), 199 deletions(-) diff --git a/cspell.json b/cspell.json index 1475b798..4b9e2429 100644 --- a/cspell.json +++ b/cspell.json @@ -14,10 +14,11 @@ ], "words": [ "antfu", - "Demuxed", + "demuxed", + "noautohide", "noto", "nums", - "Plyr", + "plyr", "rahim", "segoe", "shiki", diff --git a/examples/html/src/frosted-eject.css b/examples/html/src/frosted-eject.css index da0f7a8e..30cecaca 100644 --- a/examples/html/src/frosted-eject.css +++ b/examples/html/src/frosted-eject.css @@ -57,23 +57,21 @@ media-container > .overlay { background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0)); backdrop-filter: saturate(1.5) brightness(0.9); transition: opacity 0.15s ease-out; - transition-delay: 500ms; - opacity: 0; -} -media-container:hover > .overlay, -media-container:has([data-paused]) > .overlay, -media-container:has([aria-expanded='true']) > .overlay { - opacity: 1; transition-duration: 100ms; transition-delay: 0ms; } +media-container[data-controls='hidden'] > .overlay { + transition-delay: 500ms; + transition-duration: 300ms; + opacity: 0; +} /* Common Surface Styles - e.g. tooltips, popovers, controls */ .surface { background-color: oklab(1 0 0 / 0.1); backdrop-filter: blur(64px) brightness(0.9) saturate(1.5); box-shadow: - inset 0 0 0 1px oklab(1 0 0 / 0.15), + inset 0 0 0 1px oklab(1 0 0 / 0.05), 0 0 0 1px oklab(0 0 0 / 0.15), oklab(0 0 0 / 0.15) 0px 1px 3px 0px, oklab(0 0 0 / 0.15) 0px 1px 2px -1px; @@ -106,24 +104,20 @@ media-container:has([aria-expanded='true']) > .overlay { gap: 0.125rem; border-radius: calc(infinity * 1px); will-change: scale, transform, filter, opacity; - scale: 0.9; - opacity: 0; - filter: blur(8px); transition-property: scale, transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; + transition-delay: 0ms; + transition-duration: 100ms; transition-timing-function: ease-out; transform-origin: bottom; color: oklab(1 0 0); } -media-container:hover > .control-bar, -media-container:has([data-paused]) > .control-bar, -media-container:has([aria-expanded='true']) > .control-bar { - opacity: 1; - scale: 1; - filter: blur(0px); - transition-delay: 0ms; - transition-duration: 100ms; +media-container[data-controls='hidden'] > .control-bar { + opacity: 0; + scale: 0.9; + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 300ms; + pointer-events: none; } /* Time Display Styling */ diff --git a/examples/html/src/minimal-eject.css b/examples/html/src/minimal-eject.css index 57a5a6f6..d7bfc687 100644 --- a/examples/html/src/minimal-eject.css +++ b/examples/html/src/minimal-eject.css @@ -53,20 +53,15 @@ media-container > .overlay { pointer-events: none; border-radius: inherit; background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0)); - transform: translateY(100%); - transition-property: transform, opacity; - transition-duration: 150ms; - transition-timing-function: ease-out; - transition-delay: 500ms; - opacity: 0; -} -media-container:hover > .overlay, -media-container:has([data-paused]) > .overlay, -media-container:has([aria-expanded='true']) > .overlay { - opacity: 1; - transform: translateY(0); - transition-duration: 100ms; + transition-property: opacity; + transition-duration: 75ms; transition-delay: 0ms; + transition-timing-function: ease-out; +} +media-container[data-controls='hidden'] > .overlay { + opacity: 0; + transition-duration: 500ms; + transition-delay: 500ms; } /* Media Control Bar UI/Styles */ @@ -74,28 +69,30 @@ media-container:has([aria-expanded='true']) > .overlay { position: absolute; bottom: 0; inset-inline: 0; - padding: 2.5rem 0.75rem 0.75rem 0.75rem; + padding: 2rem 0.375rem 0.375rem 0.375rem; display: flex; align-items: center; - gap: 0.875rem; + gap: 0.5rem; will-change: transform, filter, opacity; - transform: translateY(100%); - opacity: 0; - filter: blur(8px); transition-property: transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; + transition-delay: 0ms; + transition-duration: 75ms; transition-timing-function: ease-out; color: oklab(1 0 0); } -media-container:hover > .control-bar, -media-container:has([data-paused]) > .control-bar, -media-container:has([aria-expanded='true']) > .control-bar { - opacity: 1; - transform: translateY(0); - filter: blur(0px); - transition-delay: 0ms; - transition-duration: 100ms; +media-container[data-controls='hidden'] > .control-bar { + opacity: 0; + transform: translateY(100%); + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 500ms; + pointer-events: none; +} +@container root (width > 640px) { + .control-bar { + padding: 2.5rem 0.75rem 0.75rem 0.75rem; + gap: 0.875rem; + } } /* Time Display Styling */ diff --git a/examples/react/src/skins/ejected/frosted/frosted.css b/examples/react/src/skins/ejected/frosted/frosted.css index b542e9e3..cbe4e071 100644 --- a/examples/react/src/skins/ejected/frosted/frosted.css +++ b/examples/react/src/skins/ejected/frosted/frosted.css @@ -55,23 +55,21 @@ background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0)); backdrop-filter: saturate(1.5) brightness(0.9); transition: opacity 0.15s ease-out; - transition-delay: 500ms; - opacity: 0; -} -.vjs-frosted-skin:hover > .overlay, -.vjs-frosted-skin:has([data-paused]) > .overlay, -.vjs-frosted-skin:has([aria-expanded='true']) > .overlay { - opacity: 1; transition-duration: 100ms; transition-delay: 0ms; } +.vjs-frosted-skin[data-controls='hidden'] > .overlay { + transition-delay: 500ms; + transition-duration: 300ms; + opacity: 0; +} /* Common Surface Styles - e.g. tooltips, popovers, controls */ .vjs-frosted-skin .surface { background-color: oklab(1 0 0 / 0.1); backdrop-filter: blur(64px) brightness(0.9) saturate(1.5); box-shadow: - inset 0 0 0 1px oklab(1 0 0 / 0.15), + inset 0 0 0 1px oklab(1 0 0 / 0.05), 0 0 0 1px oklab(0 0 0 / 0.15), oklab(0 0 0 / 0.15) 0px 1px 3px 0px, oklab(0 0 0 / 0.15) 0px 1px 2px -1px; @@ -102,26 +100,22 @@ display: flex; align-items: center; gap: 0.125rem; + color: oklab(1 0 0); border-radius: calc(infinity * 1px); will-change: scale, transform, filter, opacity; - scale: 0.9; - opacity: 0; - filter: blur(8px); transition-property: scale, transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; transition-timing-function: ease-out; - transform-origin: bottom; - color: oklab(1 0 0); -} -.vjs-frosted-skin:hover > .control-bar, -.vjs-frosted-skin:has([data-paused]) > .control-bar, -.vjs-frosted-skin:has([aria-expanded='true']) > .control-bar { - opacity: 1; - scale: 1; - filter: blur(0px); transition-delay: 0ms; transition-duration: 100ms; + transform-origin: bottom; +} +.vjs-frosted-skin[data-controls='hidden'] .control-bar { + pointer-events: none; + filter: blur(8px); + scale: 0.9; + opacity: 0; + transition-delay: 500ms; + transition-duration: 300ms; } /* Time Display Styling */ diff --git a/examples/react/src/skins/ejected/minimal/minimal.css b/examples/react/src/skins/ejected/minimal/minimal.css index bc678abd..4f2a3168 100644 --- a/examples/react/src/skins/ejected/minimal/minimal.css +++ b/examples/react/src/skins/ejected/minimal/minimal.css @@ -51,20 +51,15 @@ pointer-events: none; border-radius: inherit; background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0)); - transform: translateY(100%); - transition-property: transform, opacity; - transition-duration: 150ms; - transition-timing-function: ease-out; - transition-delay: 500ms; - opacity: 0; -} -.vjs-minimal-skin:hover > .overlay, -.vjs-minimal-skin:has([data-paused]) > .overlay, -.vjs-minimal-skin:has([aria-expanded='true']) > .overlay { - opacity: 1; - transform: translateY(0); - transition-duration: 100ms; + transition-property: opacity; + transition-duration: 75ms; transition-delay: 0ms; + transition-timing-function: ease-out; +} +.vjs-minimal-skin[data-controls='hidden'] > .overlay { + opacity: 0; + transition-duration: 500ms; + transition-delay: 500ms; } /* Media Control Bar UI/Styles */ @@ -72,28 +67,30 @@ position: absolute; bottom: 0; inset-inline: 0; - padding: 2.5rem 0.75rem 0.75rem 0.75rem; + padding: 2rem 0.375rem 0.375rem 0.375rem; display: flex; align-items: center; - gap: 0.875rem; + gap: 0.5rem; will-change: transform, filter, opacity; - transform: translateY(100%); - opacity: 0; - filter: blur(8px); transition-property: transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; + transition-delay: 0ms; + transition-duration: 75ms; transition-timing-function: ease-out; color: oklab(1 0 0); } -.vjs-minimal-skin:hover > .control-bar, -.vjs-minimal-skin:has([data-paused]) > .control-bar, -.vjs-minimal-skin:has([aria-expanded='true']) > .control-bar { - opacity: 1; - transform: translateY(0); - filter: blur(0px); - transition-delay: 0ms; - transition-duration: 100ms; +.vjs-minimal-skin[data-controls='hidden'] > .control-bar { + opacity: 0; + transform: translateY(100%); + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 500ms; + pointer-events: none; +} +@container root (width > 640px) { + .vjs-minimal-skin .control-bar { + padding: 2.5rem 0.75rem 0.75rem 0.75rem; + gap: 0.875rem; + } } /* Time Display Styling */ diff --git a/packages/__tech-preview__/html/src/media/media-container.ts b/packages/__tech-preview__/html/src/media/media-container.ts index b5289f55..df3780af 100644 --- a/packages/__tech-preview__/html/src/media/media-container.ts +++ b/packages/__tech-preview__/html/src/media/media-container.ts @@ -9,12 +9,26 @@ export function getTemplateHTML() { `; } +export const Attributes = { + // Inputs + AUTOHIDE: 'autohide', + AUTOHIDE_OVER_CONTROLS: 'autohideovercontrols', + // Output + CONTROLS_VISIBILITY: 'data-controls', +} as const; + +const DEFAULT_AUTOHIDE = 2000; // 2 seconds + const CustomElementConsumer: Constructor = ConsumerMixin(HTMLElement); export class MediaContainerElement extends CustomElementConsumer { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getTemplateHTML: () => string = getTemplateHTML; + static get observedAttributes(): string[] { + return [Attributes.AUTOHIDE]; + } + _mediaStore: any; _mediaSlot: HTMLSlotElement; _paused: boolean = true; @@ -27,6 +41,11 @@ export class MediaContainerElement extends CustomElementConsumer { }, }; + _pointerDownTimeStamp = 0; + _inactiveTimeout: ReturnType | undefined = undefined; + _autohide: number = DEFAULT_AUTOHIDE; + _isUserActive: boolean = true; + constructor() { super(); @@ -40,6 +59,13 @@ export class MediaContainerElement extends CustomElementConsumer { // Add click handler for play/pause functionality this.addEventListener('click', this._handleClick); + + // Add handlers for user activity detection + this.addEventListener('pointerdown', this._handlePointerDown); + this.addEventListener('pointermove', this._handlePointerMove); + this.addEventListener('pointerup', this._handlePointerUp); + this.addEventListener('mouseleave', this._setUserInactive); + this.addEventListener('keyup', this._scheduleUserInactive); } connectedCallback(): void { @@ -52,6 +78,16 @@ export class MediaContainerElement extends CustomElementConsumer { this._unregisterContainerStateOwner(); } + attributeChangedCallback( + attrName: string, + _oldValue: string, + newValue: string, + ): void { + if (attrName.toLowerCase() === Attributes.AUTOHIDE) { + this.autohide = newValue; + } + } + _registerContainerStateOwner = (): void => { if (!this._mediaStore) return; this._mediaStore.dispatch({ type: 'containerstateownerchangerequest', detail: this }); @@ -62,14 +98,24 @@ export class MediaContainerElement extends CustomElementConsumer { this._mediaStore.dispatch({ type: 'containerstateownerchangerequest', detail: null }); }; - _handleMediaSlotChange = (): void => { + _getMediaElement = (): HTMLMediaElement | null => { const media = this._mediaSlot.assignedElements({ flatten: true })[0]; + if (media && (media instanceof HTMLMediaElement)) { + return media; + } + return null; + }; + + _handleMediaSlotChange = (): void => { + const media = this._getMediaElement(); this._mediaStore.dispatch({ type: 'mediastateownerchangerequest', detail: media }); }; - _handleClick = (event: Event): void => { + _handleClick = (event: PointerEvent): void => { if (!this._mediaStore) return; - + // Ignore clicks from touch/pen devices + if (navigator.maxTouchPoints > 0 || event.pointerType !== 'mouse') return; + // Ignore clicks not on media elements if (!['video', 'audio'].includes((event.target as HTMLElement).localName || '')) return; if (this._paused) { @@ -79,12 +125,110 @@ export class MediaContainerElement extends CustomElementConsumer { } }; + _handlePointerDown = (event: PointerEvent): void => { + this._pointerDownTimeStamp = event.timeStamp; + }; + _subscribeToPlayState = (): void => { if (!this._mediaStore) return; // Subscribe to paused state changes this._mediaStore.subscribe((state: any) => { this._paused = state.paused ?? true; + this._updateControlsVisibility(); }); }; + + _handlePointerMove = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse') { + // On mobile we toggle the controls on a tap which is handled in pointerup, + // but Android fires pointermove events even when the user is just tapping. + // Prevent calling setActive() on tap because it will mess with the toggle logic. + const MAX_TAP_DURATION = 250; + // If the move duration exceeds 250ms then it's a drag and we should show the controls. + if (event.timeStamp - this._pointerDownTimeStamp < MAX_TAP_DURATION) return; + } + + this._setUserActive(); + + // Stay visible if hovered over control bar + clearTimeout(this._inactiveTimeout); + + const media = this._getMediaElement(); + + // If hovering over something other than controls, we're free to make inactive + const autohideOverControls = this.hasAttribute(Attributes.AUTOHIDE_OVER_CONTROLS); + if ([this, media].includes(event.target as HTMLMediaElement) || autohideOverControls) { + this._scheduleUserInactive(); + } + }; + + _handlePointerUp = (event: PointerEvent): void => { + if (navigator.maxTouchPoints > 0 || event.pointerType !== 'mouse') { + const media = this._getMediaElement(); + + // Toggle behavior: if user is active, make inactive; otherwise make active + if ( + [this, media].includes(event.target as HTMLMediaElement) + && this._isUserActive + ) { + this._setUserInactive(); + } else { + this._scheduleUserInactive(); + } + } else if ( + event + .composedPath() + .some(element => + element instanceof HTMLElement && ['media-play-button', 'media-fullscreen-button'].includes( + element?.localName, + ), + ) + ) { + this._scheduleUserInactive(); + } + }; + + _updateControlsVisibility = (): void => { + // Match React logic: visible if user active OR paused + const shouldBeVisible = this._isUserActive || this._paused; + const targetValue = shouldBeVisible ? 'visible' : 'hidden'; + + if (this.getAttribute(Attributes.CONTROLS_VISIBILITY) !== targetValue) { + this.setAttribute(Attributes.CONTROLS_VISIBILITY, targetValue); + } + }; + + _setUserActive = (): void => { + this._isUserActive = true; + this._updateControlsVisibility(); + }; + + _setUserInactive = (): void => { + if (typeof this._autohide !== 'number' || this._autohide < 0) return; + this._isUserActive = false; + this._updateControlsVisibility(); + }; + + _scheduleUserInactive = (): void => { + this._setUserActive(); + clearTimeout(this._inactiveTimeout); + const autohide = Number.parseInt(this.autohide); + + // Setting autohide to -1 turns off autohide + if (autohide < 0) return; + + this._inactiveTimeout = setTimeout(() => { + this._setUserInactive(); + }, autohide); + }; + + set autohide(ms: string) { + const parsed = Number(ms); + this._autohide = Number.isNaN(parsed) ? 0 : parsed; + } + + get autohide(): string { + return (this._autohide === undefined ? DEFAULT_AUTOHIDE : this._autohide).toString(); + } } diff --git a/packages/__tech-preview__/html/src/skins/frosted/styles.css b/packages/__tech-preview__/html/src/skins/frosted/styles.css index 71a988ca..03989293 100644 --- a/packages/__tech-preview__/html/src/skins/frosted/styles.css +++ b/packages/__tech-preview__/html/src/skins/frosted/styles.css @@ -54,16 +54,15 @@ media-container > .overlay { border-radius: inherit; background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0)); backdrop-filter: saturate(1.5) brightness(0.9); - transition: opacity 0.15s ease-out; - transition-delay: 500ms; - opacity: 0; -} -media-container:hover > .overlay, -media-container:has([data-paused]) > .overlay, -media-container:has([aria-expanded='true']) > .overlay { - opacity: 1; + transition-property: opacity; transition-duration: 100ms; transition-delay: 0ms; + transition-timing-function: ease-out; +} +media-container[data-controls='hidden'] > .overlay { + transition-delay: 500ms; + transition-duration: 300ms; + opacity: 0; } /* Common Surface Styles - e.g. tooltips, popovers, controls */ @@ -71,7 +70,7 @@ media-container:has([aria-expanded='true']) > .overlay { background-color: oklab(1 0 0 / 0.1); backdrop-filter: blur(64px) brightness(0.9) saturate(1.5); box-shadow: - inset 0 0 0 1px oklab(1 0 0 / 0.15), + inset 0 0 0 1px oklab(1 0 0 / 0.05), 0 0 0 1px oklab(0 0 0 / 0.15), oklab(0 0 0 / 0.15) 0px 1px 3px 0px, oklab(0 0 0 / 0.15) 0px 1px 2px -1px; @@ -104,24 +103,20 @@ media-container:has([aria-expanded='true']) > .overlay { gap: 0.125rem; border-radius: calc(infinity * 1px); will-change: scale, transform, filter, opacity; - scale: 0.9; - opacity: 0; - filter: blur(8px); transition-property: scale, transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; + transition-delay: 0ms; + transition-duration: 100ms; transition-timing-function: ease-out; transform-origin: bottom; color: oklab(1 0 0); } -media-container:hover > .control-bar, -media-container:has([data-paused]) > .control-bar, -media-container:has([aria-expanded='true']) > .control-bar { - opacity: 1; - scale: 1; - filter: blur(0px); - transition-delay: 0ms; - transition-duration: 100ms; +media-container[data-controls='hidden'] > .control-bar { + opacity: 0; + scale: 0.9; + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 300ms; + pointer-events: none; } /* Time Display Styling */ diff --git a/packages/__tech-preview__/html/src/skins/minimal/styles.css b/packages/__tech-preview__/html/src/skins/minimal/styles.css index 54c4a1f1..bcb1f723 100644 --- a/packages/__tech-preview__/html/src/skins/minimal/styles.css +++ b/packages/__tech-preview__/html/src/skins/minimal/styles.css @@ -51,49 +51,46 @@ media-container > .overlay { pointer-events: none; border-radius: inherit; background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0)); - transform: translateY(100%); - transition-property: transform, opacity; - transition-duration: 150ms; + transition-property: opacity; + transition-duration: 75ms; transition-timing-function: ease-out; - transition-delay: 500ms; - opacity: 0; -} -media-container:hover > .overlay, -media-container:has([data-paused]) > .overlay, -media-container:has([aria-expanded='true']) > .overlay { - opacity: 1; - transform: translateY(0); - transition-duration: 100ms; transition-delay: 0ms; } +media-container[data-controls='hidden'] > .overlay { + opacity: 0; + transition-duration: 500ms; + transition-delay: 500ms; +} /* Media Control Bar UI/Styles */ .control-bar { position: absolute; bottom: 0; inset-inline: 0; - padding: 2.5rem 0.75rem 0.75rem 0.75rem; + padding: 2rem 0.375rem 0.375rem 0.375rem; display: flex; align-items: center; - gap: 0.875rem; + gap: 0.5rem; will-change: transform, filter, opacity; - transform: translateY(100%); - opacity: 0; - filter: blur(8px); transition-property: transform, filter, opacity; - transition-delay: 500ms; - transition-duration: 300ms; + transition-delay: 0ms; + transition-duration: 75ms; transition-timing-function: ease-out; color: oklab(1 0 0); } -media-container:hover > .control-bar, -media-container:has([data-paused]) > .control-bar, -media-container:has([aria-expanded='true']) > .control-bar { - opacity: 1; - transform: translateY(0); - filter: blur(0px); - transition-delay: 0ms; - transition-duration: 100ms; +media-container[data-controls='hidden'] > .control-bar { + opacity: 0; + transform: translateY(100%); + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 500ms; + pointer-events: none; +} +@container root (width > 640px) { + .control-bar { + padding: 2.5rem 0.75rem 0.75rem 0.75rem; + gap: 0.875rem; + } } /* Time Display Styling */ diff --git a/packages/__tech-preview__/react/src/components/FullscreenButton.tsx b/packages/__tech-preview__/react/src/components/FullscreenButton.tsx index 816d3ed0..c58f7d1e 100644 --- a/packages/__tech-preview__/react/src/components/FullscreenButton.tsx +++ b/packages/__tech-preview__/react/src/components/FullscreenButton.tsx @@ -41,6 +41,7 @@ export function getFullscreenButtonProps( 'aria-label': state.fullscreen ? 'exit fullscreen' : 'enter fullscreen', /** tooltip */ 'data-tooltip': state.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen', + 'data-button': 'fullscreen', /** external props spread last to allow for overriding */ ...props, }; diff --git a/packages/__tech-preview__/react/src/components/MediaContainer.tsx b/packages/__tech-preview__/react/src/components/MediaContainer.tsx index 2484fdcd..b810b936 100644 --- a/packages/__tech-preview__/react/src/components/MediaContainer.tsx +++ b/packages/__tech-preview__/react/src/components/MediaContainer.tsx @@ -1,12 +1,24 @@ -import type { FC, HTMLProps, PropsWithChildren, RefCallback } from 'react'; +import type { FC, HTMLProps, PointerEventHandler, PropsWithChildren, RefCallback } from 'react'; import { playButtonStateDefinition } from '@videojs/core-preview/store'; - import { shallowEqual } from '@videojs/utils-preview'; -import { forwardRef, useCallback, useMemo } from 'react'; +import { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; import { useMediaSelector, useMediaStore } from '@/store'; import { useComposedRefs } from '../utils/use-composed-refs'; +export interface MediaContainerProps extends PropsWithChildren> { + /** + * Time in milliseconds before the controls autohide. -1 to disable autohide. + * @default 2000 + */ + autohide?: number; + /** + * Whether to autohide the controls when hovering over them. + * @default false + */ + autohideOverControls?: boolean; +} + /** * Hook to associate a React element as the fullscreen container for the media store. * This is equivalent to Media Chrome's useMediaFullscreenRef but for VJS-10. @@ -54,16 +66,32 @@ export function useMediaContainerRef(): RefCallback { * * ); */ -export const MediaContainer: FC>> = forwardRef( - ({ children, ...props }, ref) => { +export const MediaContainer: FC = forwardRef( + ({ children, autohide = 2000, autohideOverControls = false, ...props }, ref) => { const containerRef = useMediaContainerRef(); - const composedRef = useComposedRefs(ref, containerRef); + const internalRef = useRef(null); + const composedRef = useComposedRefs(ref, containerRef, internalRef); const mediaStore = useMediaStore(); const mediaState = useMediaSelector(playButtonStateDefinition.stateTransform, shallowEqual); const methods = useMemo(() => playButtonStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]); - const handleClick = useCallback((event: React.MouseEvent) => { + const [isUserActive, setIsUserActive] = useState(true); + const [pointerDownTimeStamp, setPointerDownTimeStamp] = useState(0); + const inactiveTimeoutRef = useRef | undefined>(undefined); + + const getMediaElement = useCallback((): HTMLMediaElement | null => { + const media = internalRef.current?.querySelector('video, audio'); + if (media && (media instanceof HTMLMediaElement)) { + return media; + } + return null; + }, []); + + const handleClick: PointerEventHandler = useCallback((event) => { + // Ignore clicks from touch/pen devices + if (navigator.maxTouchPoints > 0 || event.nativeEvent.pointerType !== 'mouse') return; + // Ignore clicks not on media elements if (!['video', 'audio'].includes((event.target as HTMLElement).localName || '')) return; if (mediaState.paused) { @@ -73,12 +101,84 @@ export const MediaContainer: FC>> = } }, [mediaState.paused, methods]); + const setUserInactive = useCallback(() => { + if (autohide < 0 || !internalRef.current) return; + setIsUserActive(false); + }, [autohide]); + + const scheduleUserInactive = useCallback(() => { + setIsUserActive(true); + clearTimeout(inactiveTimeoutRef.current); + + // Setting autohide to -1 turns off autohide + if (autohide < 0) return; + + inactiveTimeoutRef.current = setTimeout(() => { + setUserInactive(); + }, autohide); + }, [autohide, setUserInactive]); + + const handlePointerMove: PointerEventHandler = useCallback((event) => { + if (event.nativeEvent.pointerType !== 'mouse') { + // On mobile we toggle the controls on a tap which is handled in pointerup, + // but Android fires pointermove events even when the user is just tapping. + // Prevent calling setActive() on tap because it will mess with the toggle logic. + const MAX_TAP_DURATION = 250; + // If the move duration exceeds 250ms then it's a drag and we should show the controls. + if (event.timeStamp - pointerDownTimeStamp < MAX_TAP_DURATION) return; + } + + setIsUserActive(true); + + // Stay visible if hovered over control bar + clearTimeout(inactiveTimeoutRef.current); + + const media = getMediaElement(); + + // If hovering over something other than controls, we're free to make inactive + if ([internalRef.current, media].includes(event.target as HTMLMediaElement) || autohideOverControls) { + scheduleUserInactive(); + } + }, [autohideOverControls, getMediaElement, pointerDownTimeStamp, scheduleUserInactive]); + + const handlePointerUp: PointerEventHandler = useCallback((event) => { + if (navigator.maxTouchPoints > 0 || event.nativeEvent.pointerType !== 'mouse') { + const media = getMediaElement(); + if ( + [internalRef.current, media].includes(event.target as HTMLMediaElement) + && isUserActive + ) { + setIsUserActive(false); + } else { + scheduleUserInactive(); + } + } else if ( + event.nativeEvent + .composedPath() + .some((element) => { + if (!(element instanceof HTMLElement)) return false; + const type = element.getAttribute('data-button'); + if (!type) return false; + return ['play', 'fullscreen'].includes(type); + }, + ) + ) { + scheduleUserInactive(); + } + }, [getMediaElement, isUserActive, scheduleUserInactive]); + return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
setPointerDownTimeStamp(event.timeStamp)} + onPointerUp={handlePointerUp} + onMouseLeave={() => setUserInactive()} + onKeyUp={() => scheduleUserInactive()} data-media-container + data-controls={isUserActive || mediaState.paused ? 'visible' : 'hidden'} {...props} > {children} diff --git a/packages/__tech-preview__/react/src/components/MuteButton.tsx b/packages/__tech-preview__/react/src/components/MuteButton.tsx index 6da96961..d0679185 100644 --- a/packages/__tech-preview__/react/src/components/MuteButton.tsx +++ b/packages/__tech-preview__/react/src/components/MuteButton.tsx @@ -42,6 +42,7 @@ export function getMuteButtonProps( 'aria-label': state.muted ? 'unmute' : 'mute', /** tooltip */ 'data-tooltip': state.muted ? 'Unmute' : 'Mute', + 'data-button': 'mute', /** external props spread last to allow for overriding */ ...props, }; diff --git a/packages/__tech-preview__/react/src/components/PlayButton.tsx b/packages/__tech-preview__/react/src/components/PlayButton.tsx index 3f9156bd..de9e10a9 100644 --- a/packages/__tech-preview__/react/src/components/PlayButton.tsx +++ b/packages/__tech-preview__/react/src/components/PlayButton.tsx @@ -38,6 +38,7 @@ export function getPlayButtonProps( 'aria-label': state.paused ? 'play' : 'pause', /** tooltip */ 'data-tooltip': state.paused ? 'Play' : 'Pause', + 'data-button': 'play', /** external props spread last to allow for overriding */ ...props, }; diff --git a/packages/__tech-preview__/react/src/skins/frosted/styles.ts b/packages/__tech-preview__/react/src/skins/frosted/styles.ts index 0a6a545d..35145cfb 100644 --- a/packages/__tech-preview__/react/src/skins/frosted/styles.ts +++ b/packages/__tech-preview__/react/src/skins/frosted/styles.ts @@ -24,18 +24,16 @@ const styles: FrostedSkinStyles = { 'vjs:[&_video]:w-full vjs:[&_video]:h-full', ), Overlay: cn( - 'vjs:opacity-0 vjs:delay-500 vjs:duration-300 vjs:rounded-[inherit] vjs:absolute vjs:inset-0 vjs:pointer-events-none vjs:bg-linear-to-t vjs:from-black/50 vjs:via-black/20 vjs:to-transparent vjs:transition-opacity vjs:backdrop-saturate-150 vjs:backdrop-brightness-90', - // ------------------------------------ - // FIXME: This is crude temporary logic, we'll improve it later I guess with a [data-show-controls] attribute or something? - 'vjs:has-[+.controls_[data-paused]]:opacity-100 vjs:has-[+.controls_[data-paused]]:delay-0 vjs:has-[+.controls_[data-paused]]:duration-100', - 'vjs:has-[+.controls_[aria-expanded="true"]]:opacity-100 vjs:has-[+.controls_[aria-expanded="true"]]:delay-0 vjs:has-[+.controls_[aria-expanded="true"]]:duration-100', - 'vjs:group-hover/root:opacity-100 vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100', - // ------------------------------------ + 'vjs:rounded-[inherit] vjs:absolute vjs:inset-0 vjs:pointer-events-none vjs:bg-linear-to-t vjs:from-black/50 vjs:via-black/20 vjs:to-transparent vjs:transition-opacity vjs:backdrop-saturate-150 vjs:backdrop-brightness-90', + // No delay and faster duration when showing + 'vjs:delay-0 vjs:duration-100', + // Hidden state + 'vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-300 vjs:group-data-[controls=hidden]/root:opacity-0', ), Surface: cn( 'vjs:bg-white/10 vjs:backdrop-blur-3xl vjs:backdrop-saturate-150 vjs:backdrop-brightness-90', // Ring and shadow - 'vjs:ring vjs:ring-white/10 vjs:ring-inset vjs:shadow-sm vjs:shadow-black/15', + 'vjs:ring vjs:ring-white/5 vjs:ring-inset vjs:shadow-sm vjs:shadow-black/15', // Border to enhance contrast on lighter videos 'vjs:after:absolute vjs:after:inset-0 vjs:after:ring vjs:after:rounded-[inherit] vjs:after:ring-black/15 vjs:after:pointer-events-none vjs:after:z-10', // Reduced transparency for users with preference @@ -45,20 +43,13 @@ const styles: FrostedSkinStyles = { 'vjs:contrast-more:bg-black/90 vjs:contrast-more:ring-black vjs:contrast-more:after:ring-white/20', ), Controls: cn( - // ------------------------------------ - // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility. - 'controls', - // ------------------------------------ 'vjs:@container/controls vjs:absolute vjs:inset-x-3 vjs:bottom-3 vjs:rounded-full vjs:flex vjs:items-center vjs:p-1 vjs:gap-0.5 vjs:text-white', // Animation 'vjs:transition vjs:will-change-[transform,scale,filter,opacity] vjs:origin-bottom vjs:ease-out', - // ------------------------------------ - // FIXME: Temporary hide/show logic, related to above. - 'vjs:scale-90 vjs:opacity-0 vjs:blur-sm vjs:delay-500 vjs:duration-300', - 'vjs:has-data-paused:scale-100 vjs:has-data-paused:opacity-100 vjs:has-data-paused:blur-none vjs:has-data-paused:delay-0 vjs:has-data-paused:duration-100', - 'vjs:has-[[aria-expanded="true"]]:scale-100 vjs:has-[[aria-expanded="true"]]:opacity-100 vjs:has-[[aria-expanded="true"]]:blur-none vjs:has-[[aria-expanded="true"]]:delay-0 vjs:has-[[aria-expanded="true"]]:duration-100', - 'vjs:group-hover/root:scale-100 vjs:group-hover/root:opacity-100 vjs:group-hover/root:blur-none vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100', - // ------------------------------------ + // No delay and faster duration when showing + 'vjs:delay-0 vjs:duration-100', + // Hidden state + 'vjs:group-data-[controls=hidden]/root:scale-90 vjs:group-data-[controls=hidden]/root:opacity-0 vjs:group-data-[controls=hidden]/root:blur-sm vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-300 vjs:group-data-[controls=hidden]/root:pointer-events-none', ), Icon: cn('icon vjs:[&_path]:transition-transform vjs:[&_path]:ease-out'), Button: cn( diff --git a/packages/__tech-preview__/react/src/skins/minimal/styles.ts b/packages/__tech-preview__/react/src/skins/minimal/styles.ts index 50746a30..25d15ca5 100644 --- a/packages/__tech-preview__/react/src/skins/minimal/styles.ts +++ b/packages/__tech-preview__/react/src/skins/minimal/styles.ts @@ -25,31 +25,25 @@ const styles: MinimalSkinStyles = { Overlay: cn( 'vjs:absolute vjs:inset-0 vjs:rounded-[inherit] vjs:pointer-events-none', 'vjs:bg-linear-to-t vjs:from-black/70 vjs:via-black/50 vjs:via-[7.5rem] vjs:to-transparent', - 'vjs:opacity-0 vjs:delay-500 vjs:duration-300', // High contrast mode 'vjs:contrast-more:from-black/75', - // ------------------------------------ - // FIXME: Temporary hide/show logic - 'vjs:has-[+.controls_[data-paused]]:opacity-100 vjs:has-[+.controls_[data-paused]]:delay-0 vjs:has-[+.controls_[data-paused]]:duration-75', - 'vjs:has-[+.controls_[aria-expanded="true"]]:opacity-100 vjs:has-[+.controls_[aria-expanded="true"]]:delay-0 vjs:has-[+.controls_[aria-expanded="true"]]:duration-75', - 'vjs:group-hover/root:opacity-100 vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-75', - // ------------------------------------ + // Animation + 'vjs:transition vjs:will-change-[opacity] vjs:ease-out', + // No delay and faster duration when showing + 'vjs:delay-0 vjs:duration-75', + // Hidden state + 'vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-500 vjs:group-data-[controls=hidden]/root:opacity-0', ), Controls: cn( - // ------------------------------------ - // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility. - 'controls', - // ------------------------------------ - 'vjs:@container/controls vjs:absolute vjs:inset-x-0 vjs:bottom-0 vjs:flex vjs:items-center vjs:gap-3.5 vjs:z-20 vjs:px-3 vjs:pb-3 vjs:pt-10 vjs:text-white', + 'vjs:@container/controls vjs:absolute vjs:inset-x-0 vjs:bottom-0 vjs:flex vjs:items-center vjs:gap-2 vjs:z-20 vjs:px-1.5 vjs:pb-1.5 vjs:pt-8 vjs:text-white', // Animation 'vjs:transition vjs:will-change-[transform,filter,opacity] vjs:ease-out', - // ------------------------------------ - // FIXME: Temporary hide/show logic - 'vjs:translate-y-full vjs:opacity-0 vjs:blur-sm vjs:delay-500 vjs:duration-300', - 'vjs:has-data-paused:translate-y-0 vjs:has-data-paused:opacity-100 vjs:has-data-paused:blur-none vjs:has-data-paused:delay-0 vjs:has-data-paused:duration-75', - 'vjs:has-[[aria-expanded="true"]]:translate-y-0 vjs:has-[[aria-expanded="true"]]:opacity-100 vjs:has-[[aria-expanded="true"]]:blur-none vjs:has-[[aria-expanded="true"]]:delay-0 vjs:has-[[aria-expanded="true"]]:duration-75', - 'vjs:group-hover/root:translate-y-0 vjs:group-hover/root:opacity-100 vjs:group-hover/root:blur-none vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-75', - // ------------------------------------ + // No delay and faster duration when showing + 'vjs:delay-0 vjs:duration-75', + // Hidden state + 'vjs:group-data-[controls=hidden]/root:translate-y-full vjs:group-data-[controls=hidden]/root:opacity-0 vjs:group-data-[controls=hidden]/root:blur-sm vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-500 vjs:group-data-[controls=hidden]/root:pointer-events-none', + // Looser spacing on larger screens + 'vjs:@sm/root:gap-3.5 vjs:@sm/root:px-3 vjs:@sm/root:pb-3 vjs:@sm/root:pt-10', ), Icon: cn('icon vjs:[&_path]:transition-transform vjs:[&_path]:ease-out'), TimeDisplayGroup: cn('flex items-center gap-1'),