mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add user activity logic (#278)
This commit is contained in:
+3
-2
@@ -14,10 +14,11 @@
|
||||
],
|
||||
"words": [
|
||||
"antfu",
|
||||
"Demuxed",
|
||||
"demuxed",
|
||||
"noautohide",
|
||||
"noto",
|
||||
"nums",
|
||||
"Plyr",
|
||||
"plyr",
|
||||
"rahim",
|
||||
"segoe",
|
||||
"shiki",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<CustomElement & HTMLElement> = 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<typeof setTimeout> | 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<HTMLProps<HTMLDivElement>> {
|
||||
/**
|
||||
* 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<HTMLElement | null> {
|
||||
* </MediaContainer>
|
||||
* );
|
||||
*/
|
||||
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = forwardRef(
|
||||
({ children, ...props }, ref) => {
|
||||
export const MediaContainer: FC<MediaContainerProps> = forwardRef(
|
||||
({ children, autohide = 2000, autohideOverControls = false, ...props }, ref) => {
|
||||
const containerRef = useMediaContainerRef();
|
||||
const composedRef = useComposedRefs(ref, containerRef);
|
||||
const internalRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
const [isUserActive, setIsUserActive] = useState(true);
|
||||
const [pointerDownTimeStamp, setPointerDownTimeStamp] = useState(0);
|
||||
const inactiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLDivElement> = 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<PropsWithChildren<HTMLProps<HTMLDivElement>>> =
|
||||
}
|
||||
}, [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<HTMLDivElement> = 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<HTMLDivElement> = 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
|
||||
<div
|
||||
ref={composedRef}
|
||||
onClick={handleClick}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={event => setPointerDownTimeStamp(event.timeStamp)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onMouseLeave={() => setUserInactive()}
|
||||
onKeyUp={() => scheduleUserInactive()}
|
||||
data-media-container
|
||||
data-controls={isUserActive || mediaState.paused ? 'visible' : 'hidden'}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user