diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts
index eb915247..1e578887 100644
--- a/packages/html/html/src/components/media-popover.ts
+++ b/packages/html/html/src/components/media-popover.ts
@@ -165,16 +165,23 @@ export class MediaPopoverPortal extends HTMLElement {
}
#setupPortal(): void {
- const portalId = this.getAttribute('root-id');
+ const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
if (!portalId) return;
- const portalContainer = (this.getRootNode() as ShadowRoot | Document).getElementById(portalId);
+ /* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
+ // NOTE: Hacky solution in part to ensure styling propogates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
+ const portalContainer
+ = ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
+ ?? (this.getRootNode() as ShadowRoot | Document)
+ .querySelector('media-container')
+ ?.shadowRoot
+ ?.getElementById(portalId))
+ ? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
+ : undefined;
if (!portalContainer) return;
- portalContainer.addEventListener('mouseenter', this);
- portalContainer.addEventListener('mouseleave', this);
-
this.#portal = document.createElement('div');
+ this.#portal.slot = 'portal';
this.#portal.id = uniqueId();
portalContainer.append(this.#portal);
diff --git a/packages/html/html/src/components/media-preview-time-display.ts b/packages/html/html/src/components/media-preview-time-display.ts
new file mode 100644
index 00000000..e7ab9353
--- /dev/null
+++ b/packages/html/html/src/components/media-preview-time-display.ts
@@ -0,0 +1,85 @@
+import type { PreviewTimeDisplayState } from '@vjs-10/media-store';
+import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
+
+import { formatDisplayTime, previewTimeDisplayStateDefinition } from '@vjs-10/media-store';
+
+import { toConnectedHTMLComponent } from '../utils/component-factory';
+
+export class PreviewTimeDisplayBase extends HTMLElement {
+ static shadowRootOptions = {
+ mode: 'open' as ShadowRootMode,
+ };
+
+ static observedAttributes: string[] = ['show-remaining'];
+
+ _state:
+ | {
+ previewTime: number | undefined;
+ }
+ | undefined;
+
+ constructor() {
+ super();
+
+ if (!this.shadowRoot) {
+ this.attachShadow((this.constructor as typeof PreviewTimeDisplayBase).shadowRootOptions);
+ }
+ }
+
+ get previewTime(): number {
+ return this._state?.previewTime ?? 0;
+ }
+
+ get showRemaining(): boolean {
+ return this.hasAttribute('show-remaining');
+ }
+
+ attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
+ if (name === 'show-remaining' && this._state) {
+ // Re-render with current state when show-remaining attribute changes
+ this._update({}, this._state);
+ }
+ }
+
+ _update(_props: any, state: any): void {
+ this._state = state;
+
+ /** @TODO Should this live here or elsewhere? (CJP) */
+ const timeLabel = formatDisplayTime(state.previewTime);
+
+ if (this.shadowRoot) {
+ this.shadowRoot.textContent = timeLabel;
+ }
+ }
+}
+
+export const usePreviewTimeDisplayState: StateHook<{
+ previewTime: number | undefined;
+}> = {
+ keys: [...previewTimeDisplayStateDefinition.keys],
+ transform: (rawState, _mediaStore) => ({
+ ...previewTimeDisplayStateDefinition.stateTransform(rawState),
+ // Preview time display is read-only, so no request methods needed
+ }),
+};
+
+export const usePreviewTimeDisplayProps: PropsHook<{
+ previewTime: number | undefined;
+}> = (_state, _element) => {
+ const baseProps: Record = {};
+ return baseProps;
+};
+
+export const PreviewTimeDisplay: ConnectedComponentConstructor = toConnectedHTMLComponent(
+ PreviewTimeDisplayBase,
+ usePreviewTimeDisplayState,
+ usePreviewTimeDisplayProps,
+ 'PreviewTimeDisplay',
+);
+
+// Register the custom element
+if (!globalThis.customElements.get('preview-time-display')) {
+ globalThis.customElements.define('preview-time-display', PreviewTimeDisplay);
+}
+
+export default PreviewTimeDisplay;
diff --git a/packages/html/html/src/components/media-tooltip.ts b/packages/html/html/src/components/media-tooltip.ts
index 3cbd0772..5cb538bf 100644
--- a/packages/html/html/src/components/media-tooltip.ts
+++ b/packages/html/html/src/components/media-tooltip.ts
@@ -10,11 +10,13 @@ export class MediaTooltipRoot extends HTMLElement {
#hoverTimeout: ReturnType | null = null;
#cleanup: (() => void) | null = null;
#arrowElement: HTMLElement | null = null;
+ #mousePosition = { x: 0, y: 0 };
constructor() {
super();
this.addEventListener('mouseenter', this);
this.addEventListener('mouseleave', this);
+ this.addEventListener('mousemove', this);
}
handleEvent(event: Event): void {
@@ -22,6 +24,8 @@ export class MediaTooltipRoot extends HTMLElement {
this.#handleMouseEnter();
} else if (event.type === 'mouseleave') {
this.#handleMouseLeave();
+ } else if (event.type === 'mousemove') {
+ this.#handleMouseMove(event as MouseEvent);
}
}
@@ -35,17 +39,22 @@ export class MediaTooltipRoot extends HTMLElement {
}
static get observedAttributes(): string[] {
- return ['delay', 'close-delay'];
+ return ['delay', 'close-delay', 'track-cursor-axis'];
}
get delay(): number {
- return Number.parseInt(this.getAttribute('delay') ?? '600', 10);
+ return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
}
get closeDelay(): number {
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
}
+ get trackCursorAxis(): 'x' | 'y' | 'both' | undefined {
+ const value = this.getAttribute('track-cursor-axis');
+ return value === 'x' || value === 'y' || value === 'both' ? value : undefined;
+ }
+
get #triggerElement(): MediaTooltipTrigger | null {
return this.querySelector('media-tooltip-trigger') as MediaTooltipTrigger | null;
}
@@ -121,7 +130,54 @@ export class MediaTooltipRoot extends HTMLElement {
middleware.push(arrow({ element: this.#arrowElement }));
}
- computePosition(trigger, popup, {
+ // Use cursor position for positioning if trackCursorAxis is enabled
+ const referenceElement = this.trackCursorAxis
+ ? {
+ getBoundingClientRect: () => {
+ const triggerRect = trigger.getBoundingClientRect();
+
+ if (this.trackCursorAxis === 'x') {
+ // Track horizontal cursor position, use trigger's vertical position
+ return {
+ width: 0,
+ height: 0,
+ top: triggerRect.top,
+ right: this.#mousePosition.x,
+ bottom: triggerRect.bottom,
+ left: this.#mousePosition.x,
+ x: this.#mousePosition.x,
+ y: triggerRect.top,
+ };
+ } else if (this.trackCursorAxis === 'y') {
+ // Track vertical cursor position, use trigger's horizontal position
+ return {
+ width: 0,
+ height: 0,
+ top: this.#mousePosition.y,
+ right: triggerRect.right,
+ bottom: this.#mousePosition.y,
+ left: triggerRect.left,
+ x: triggerRect.left,
+ y: this.#mousePosition.y,
+ };
+ } else {
+ // Track both axes (trackCursorAxis === 'both')
+ return {
+ width: 0,
+ height: 0,
+ top: this.#mousePosition.y,
+ right: this.#mousePosition.x,
+ bottom: this.#mousePosition.y,
+ left: this.#mousePosition.x,
+ x: this.#mousePosition.x,
+ y: this.#mousePosition.y,
+ };
+ }
+ },
+ }
+ : trigger;
+
+ computePosition(referenceElement, popup, {
placement,
middleware,
}).then(({ x, y, middlewareData }: { x: number; y: number; middlewareData: any }) => {
@@ -142,7 +198,16 @@ export class MediaTooltipRoot extends HTMLElement {
};
updatePosition();
- this.#cleanup = autoUpdate(trigger, popup, updatePosition);
+
+ if (!this.trackCursorAxis) {
+ this.#cleanup = autoUpdate(trigger, popup, updatePosition);
+ }
+ }
+
+ #updatePosition(): void {
+ if (this.#open && this.trackCursorAxis) {
+ this.#setupFloating();
+ }
}
#clearHoverTimeout(): void {
@@ -165,6 +230,16 @@ export class MediaTooltipRoot extends HTMLElement {
this.#setOpen(false);
}, this.closeDelay);
}
+
+ #handleMouseMove(event: MouseEvent): void {
+ if (this.trackCursorAxis) {
+ this.#mousePosition = { x: event.clientX, y: event.clientY };
+ // Update position if tooltip is open
+ if (this.#open) {
+ this.#updatePosition();
+ }
+ }
+ }
}
export class MediaTooltipTrigger extends HTMLElement {
@@ -195,7 +270,7 @@ export class MediaTooltipTrigger extends HTMLElement {
}
const attributeName = mutation.attributeName;
- if (!attributeName) {
+ if (!attributeName || !attributeName.startsWith('data-')) {
return;
}
diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts
index a7f2dfa6..18693af1 100644
--- a/packages/html/html/src/skins/media-skin-default.ts
+++ b/packages/html/html/src/skins/media-skin-default.ts
@@ -8,6 +8,7 @@ import '../components/media-time-slider';
import '../components/media-fullscreen-button';
import '../components/media-duration-display';
import '../components/media-current-time-display';
+import '../components/media-preview-time-display';
import '../components/media-popover';
import '../components/media-tooltip';
import '@vjs-10/html-icons';
@@ -293,13 +294,26 @@ export function getTemplateHTML() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -310,7 +324,7 @@ export function getTemplateHTML() {
-
+