feat(core): add user activity logic (#278)

This commit is contained in:
Sam Potts
2025-12-22 09:27:27 +11:00
committed by GitHub
parent b89fef56dd
commit b5a3f551f0
14 changed files with 406 additions and 199 deletions
@@ -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 */