diff --git a/packages/core/src/dom/media/hls-js/index.ts b/packages/core/src/dom/media/hls-js/index.ts index 91edd053..2d6a34ff 100644 --- a/packages/core/src/dom/media/hls-js/index.ts +++ b/packages/core/src/dom/media/hls-js/index.ts @@ -252,7 +252,7 @@ export class HlsJsMedia extends HTMLVideoElementHost implements HlsMediaProps { return { ...this.config.hlsJs, preferPlayback: this.config.preferPlayback, - contentType: this.config.contentType, + contentType: this.config.contentType ?? inferContentType(this.#src), }; } diff --git a/packages/core/src/dom/media/hls-js/tests/hls-media.test.ts b/packages/core/src/dom/media/hls-js/tests/hls-media.test.ts index 82996337..c253e859 100644 --- a/packages/core/src/dom/media/hls-js/tests/hls-media.test.ts +++ b/packages/core/src/dom/media/hls-js/tests/hls-media.test.ts @@ -3,7 +3,7 @@ import { MediaError } from '../../../../core/media/media-error'; import type { RemotePlaybackLike } from '../../../../core/media/types'; import { addComponent, type Component } from '../../media-host'; import { NativeHlsMedia } from '../../native-hls'; -import { ContentTypes, HlsJsMedia } from '../index'; +import { ContentTypes, Hls, HlsJsMedia } from '../index'; afterEach(() => { document.body.innerHTML = ''; @@ -162,6 +162,26 @@ describe('HlsJsMedia', () => { expect(handler).not.toHaveBeenCalled(); }); + it('recreates the engine when inferred content type changes', () => { + vi.spyOn(Hls, 'isSupported').mockReturnValue(true); + + const video = document.createElement('video'); + document.body.appendChild(video); + + const media = new HlsJsMedia(); + media.attach(video); + + media.src = 'https://example.com/video.mp4'; + media.load(); + + expect(media.engine).toBeNull(); + + media.src = 'https://example.com/video.m3u8'; + media.load(); + + expect(media.engine).not.toBeNull(); + }); + it('resets free-form config when a new object is assigned', () => { const { media } = setup(); diff --git a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts index 42aa7899..b5168662 100644 --- a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts +++ b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts @@ -44,6 +44,7 @@ const MENU_VIEW_ACTIVE_STATE = 'active'; const MENU_VIEW_INACTIVE_STATE = 'inactive'; const MENU_ROOT_VIEW_ATTR = 'data-menu-root-view'; const MENU_VIEWPORT_ATTR = 'data-menu-viewport'; +const MENU_VIEW_LAYOUT_ATTRS = ['data-availability']; const MENU_WIDTH_VAR = '--media-menu-width'; const MENU_HEIGHT_VAR = '--media-menu-height'; const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [ @@ -273,6 +274,34 @@ export function syncMenuViewRoot( setViewportSize(content, size); } +export function observeMenuViewContent(content: HTMLElement, onChange: () => void): () => void { + if (typeof MutationObserver === 'undefined') return () => {}; + + let rafId = 0; + + function scheduleChange(): void { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + rafId = 0; + onChange(); + }); + } + + const observer = new MutationObserver(scheduleChange); + + observer.observe(content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: MENU_VIEW_LAYOUT_ATTRS, + }); + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + }; +} + export function syncMenuViewTransition( content: HTMLElement | null, view: HTMLElement | null, diff --git a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts index c1db3f16..c30ef800 100644 --- a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts +++ b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts @@ -4,6 +4,7 @@ import { getMenuRootViewAttrs, getMenuViewportAttrs, getMenuViewportElement, + observeMenuViewContent, syncMenuViewRoot, syncMenuViewTransition, } from '../menu-viewport-transition'; @@ -38,6 +39,15 @@ function createRect(width: number, height: number): DOMRect { } as DOMRect; } +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForMutationFrame(): Promise { + await Promise.resolve(); + await nextFrame(); +} + function mockMenuViewSize( element: HTMLElement, { @@ -125,6 +135,29 @@ describe('menu-viewport-transition', () => { expect(getMenuViewportElement(content)).toBe(content); }); + it('observes child and availability changes in menu content', async () => { + const content = addElement(); + const item = document.createElement('div'); + const onChange = vi.fn(); + const cleanup = observeMenuViewContent(content, onChange); + + content.append(item); + await waitForMutationFrame(); + + expect(onChange).toHaveBeenCalledTimes(1); + + item.setAttribute('data-availability', 'available'); + await waitForMutationFrame(); + + expect(onChange).toHaveBeenCalledTimes(2); + + cleanup(); + item.setAttribute('data-availability', 'unavailable'); + await waitForMutationFrame(); + + expect(onChange).toHaveBeenCalledTimes(2); + }); + it('toggles menu viewport data attributes for active and exiting phases', () => { const content = addElement(); const rootView = document.createElement('div'); diff --git a/packages/html/src/ui/menu/menu-element.ts b/packages/html/src/ui/menu/menu-element.ts index d699b0fc..1af9be37 100644 --- a/packages/html/src/ui/menu/menu-element.ts +++ b/packages/html/src/ui/menu/menu-element.ts @@ -18,6 +18,7 @@ import { type MenuOpenChangeReason, type MenuViewTransitionState, type NavigationState, + observeMenuViewContent, type PositioningBoundary, resolveOffsets, resolvePositioningBoundary, @@ -85,6 +86,7 @@ export class MenuElement extends MediaElement { #disconnect: AbortController | null = null; #triggerAbort: AbortController | null = null; + #cleanupContentObserver: (() => void) | null = null; #currentTrigger: HTMLElement | null = null; override connectedCallback(): void { @@ -145,6 +147,8 @@ export class MenuElement extends MediaElement { override disconnectedCallback(): void { super.disconnectedCallback(); + this.#cleanupContentObserver?.(); + this.#cleanupContentObserver = null; this.#cleanupTrigger(); this.#menu?.destroy(); this.#menu = null; @@ -239,10 +243,16 @@ export class MenuElement extends MediaElement { } if (!state.open) { + this.#cleanupContentObserver?.(); + this.#cleanupContentObserver = null; this.#position.cleanup(); return; } + this.#cleanupContentObserver ??= observeMenuViewContent(this, () => { + this.requestUpdate(); + }); + syncMenuViewRoot(this, this.#navState.stack.length > 0); const positionOptions = getRootPositionOptions(state.side, state.align); diff --git a/packages/react/src/ui/menu/menu-content.tsx b/packages/react/src/ui/menu/menu-content.tsx index dbb2e8ba..ab5d8751 100644 --- a/packages/react/src/ui/menu/menu-content.tsx +++ b/packages/react/src/ui/menu/menu-content.tsx @@ -12,6 +12,7 @@ import { getRootPositionOptions, isEventWithinElement, isMenuNavigationKey, + observeMenuViewContent, resolveOffsets, resolvePositioningBoundary, syncMenuViewRoot, @@ -242,6 +243,18 @@ export const MenuContent = forwardRef(function syncMenuViewRoot(internalRef.current, activeSubMenuId !== null); }, [isSubmenu, state.open, activeSubMenuId]); + useLayoutEffect(() => { + if (isSubmenu) return; + if (!state.open) return; + + const contentElement = internalRef.current; + if (!contentElement) return; + + return observeMenuViewContent(contentElement, () => { + syncMenuViewRoot(contentElement, activeSubMenuId !== null); + }); + }, [isSubmenu, state.open, activeSubMenuId]); + useLayoutEffect(() => { if (!isSubmenu) return; diff --git a/packages/react/src/ui/menu/tests/menu.test.tsx b/packages/react/src/ui/menu/tests/menu.test.tsx index 1e694408..515da054 100644 --- a/packages/react/src/ui/menu/tests/menu.test.tsx +++ b/packages/react/src/ui/menu/tests/menu.test.tsx @@ -331,6 +331,48 @@ function expectNoMenuStateAttrs(element: HTMLElement): void { } } +function createRect(width: number, height: number): DOMRect { + return { + x: 0, + y: 0, + width, + height, + top: 0, + right: width, + bottom: height, + left: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function mockMenuViewSize(element: HTMLElement, getHeight: () => number): void { + element.getBoundingClientRect = vi.fn(() => createRect(160, getHeight())); + + Object.defineProperty(element, 'scrollWidth', { + configurable: true, + get: () => 160, + }); + + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + get: getHeight, + }); +} + +function DynamicMenuFixture({ showCaptions }: { showCaptions: boolean }) { + return ( + + Settings + + + Speed + {showCaptions ? Captions : null} + + + + ); +} + describe('MenuContent', () => { it('scopes menu state data attributes to content elements', async () => { render( @@ -457,6 +499,20 @@ describe('MenuContent', () => { expect(screen.getByTestId('root-view').hasAttribute('data-menu-view')).toBe(true); }); + it('remeasures an open root view when menu items are added', async () => { + const { rerender } = render(); + const content = screen.getByTestId('content'); + const rootView = screen.getByTestId('root-view'); + + mockMenuViewSize(rootView, () => rootView.children.length * 20); + + rerender(); + + await waitFor(() => { + expect(content.style.getPropertyValue('--media-menu-height')).toBe('40px'); + }); + }); + it('forces layout while the submenu starting style is applied', async () => { const startingStyleMeasurements: boolean[] = []; const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;