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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user