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:
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user