From 7f243c22d85f45c5f7a88e3638f03ce774667d20 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 12 Sep 2025 06:33:10 -0700 Subject: [PATCH] feat(media-store): add fullscreen state mediator with shadow DOM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive fullscreen state management: - Cross-browser fullscreen API support (webkit, moz, ms prefixes) - Advanced shadow DOM traversal for nested web components - Support for container-based fullscreen targeting - Event-driven state updates with stateOwnersUpdateHandlers - Fallback support for older Safari versions using composed node traversal The implementation handles complex scenarios where fullscreen elements are nested within shadow DOM boundaries, ensuring accurate state detection in modern web component architectures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/core/media-store/src/factory.ts | 1 + packages/core/media-store/src/media-store.ts | 3 +- .../src/state-mediators/fullscreenable.ts | 150 ++++++++++++++++++ .../media-store/src/state-mediators/index.ts | 4 + 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/core/media-store/src/state-mediators/fullscreenable.ts create mode 100644 packages/core/media-store/src/state-mediators/index.ts diff --git a/packages/core/media-store/src/factory.ts b/packages/core/media-store/src/factory.ts index 9e89996a..c4691cc2 100644 --- a/packages/core/media-store/src/factory.ts +++ b/packages/core/media-store/src/factory.ts @@ -46,6 +46,7 @@ export type StateMediator = { currentTime: FacadeProp; duration: ReadonlyFacadeProp; seekable: ReadonlyFacadeProp<[number, number] | undefined>; + fullscreen: FacadeProp; }; export function createMediaStore({ diff --git a/packages/core/media-store/src/media-store.ts b/packages/core/media-store/src/media-store.ts index f3f26347..b9d6d4ce 100644 --- a/packages/core/media-store/src/media-store.ts +++ b/packages/core/media-store/src/media-store.ts @@ -1,10 +1,11 @@ import { playable } from './state-mediators/playable'; import { audible } from './state-mediators/audible'; import { temporal } from './state-mediators/temporal'; +import { fullscreenable } from './state-mediators/fullscreenable'; import { createMediaStore as factory } from './factory'; // Example of default media store with default state mediator definitions. (CJP) // NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP) -const stateMediator = { ...playable, ...audible, ...temporal }; +const stateMediator = { ...playable, ...audible, ...temporal, ...fullscreenable }; type Params = Partial[0]>; export const createMediaStore = (params: Params = {}) => factory({ stateMediator, ...params }); \ No newline at end of file diff --git a/packages/core/media-store/src/state-mediators/fullscreenable.ts b/packages/core/media-store/src/state-mediators/fullscreenable.ts new file mode 100644 index 00000000..aae32945 --- /dev/null +++ b/packages/core/media-store/src/state-mediators/fullscreenable.ts @@ -0,0 +1,150 @@ +// Utility function to check if a root node contains a child node across shadow DOM boundaries +const containsComposedNode = (rootNode: Node, childNode: Node): boolean => { + if (!rootNode || !childNode) return false; + if (rootNode?.contains(childNode)) return true; + const childRootNode = childNode.getRootNode(); + if (childRootNode && 'host' in childRootNode && childRootNode.host) { + return containsComposedNode(rootNode, childRootNode.host as Node); + } + return false; +}; + +/** @TODO This is implemented for web/browser only! We will need an alternative state mediator model for e.g. React Native. (CJP) */ +export const fullscreenable = { + fullscreen: { + get(stateOwners: any) { + const { container } = stateOwners; + if (!container || !globalThis?.document) return false; + + const doc = globalThis.document; + const currentFullscreenElement = + doc.fullscreenElement || + (doc as any).webkitFullscreenElement || + (doc as any).mozFullScreenElement || + (doc as any).msFullscreenElement; + + if (!currentFullscreenElement) return false; + + // If document.fullscreenElement is the container, we're definitely in fullscreen + if (currentFullscreenElement === container) { + return true; + } + + // Check if container is contained within the fullscreen element + if (currentFullscreenElement.contains?.(container)) { + return true; + } + + // Handle web components with shadow DOM - traverse shadow DOM layers + // In this case (most modern browsers), the fullscreenElement may be + // a web component that contains our container within shadow DOM layers + if (currentFullscreenElement.localName?.includes('-')) { + let currentRoot = currentFullscreenElement.shadowRoot; + + // Check if ShadowRoot supports fullscreenElement (Safari < 16.4 workaround) + const fullscreenElementKey = + 'fullscreenElement' in doc + ? 'fullscreenElement' + : 'webkitFullscreenElement' in doc + ? 'webkitFullscreenElement' + : undefined; + + if ( + fullscreenElementKey && + !(fullscreenElementKey in (currentRoot || {})) + ) { + // For older Safari versions, use composed node containment check + return containsComposedNode(currentFullscreenElement, container); + } + + // Traverse shadow DOM layers looking for our container + while (fullscreenElementKey && currentRoot?.[fullscreenElementKey]) { + if (currentRoot[fullscreenElementKey] === container) return true; + if (currentRoot[fullscreenElementKey]?.contains?.(container)) + return true; + currentRoot = currentRoot[fullscreenElementKey]?.shadowRoot; + } + } + + return false; + }, + set(value: boolean, stateOwners: any) { + const { container } = stateOwners; + if (!container || !globalThis?.document) return; + + try { + if (value) { + // Enter fullscreen + if (container.requestFullscreen) { + container.requestFullscreen(); + } else if (container.webkitRequestFullscreen) { + // Safari support + container.webkitRequestFullscreen(); + } else if (container.mozRequestFullScreen) { + // Firefox support + container.mozRequestFullScreen(); + } else if (container.msRequestFullscreen) { + // IE/Edge support + container.msRequestFullscreen(); + } + } else { + // Exit fullscreen + const doc = globalThis.document as any; + if (doc.exitFullscreen) { + doc.exitFullscreen(); + } else if (doc.webkitExitFullscreen) { + // Safari support + doc.webkitExitFullscreen(); + } else if (doc.mozCancelFullScreen) { + // Firefox support + doc.mozCancelFullScreen(); + } else if (doc.msExitFullscreen) { + // IE/Edge support + doc.msExitFullscreen(); + } + } + } catch (error) { + // Gracefully handle fullscreen API errors (e.g., user interaction required) + console.warn('Fullscreen operation failed:', error); + } + }, + stateOwnersUpdateHandlers: [ + (handler: (value?: boolean) => void, _stateOwners: any) => { + if (!globalThis?.document) return; + + const eventHandler = () => handler(); + const events = [ + 'fullscreenchange', + 'webkitfullscreenchange', + 'mozfullscreenchange', + 'MSFullscreenChange', + ]; + + events.forEach((event) => { + globalThis.document.addEventListener(event, eventHandler); + }); + + return () => { + events.forEach((event) => { + globalThis.document.removeEventListener(event, eventHandler); + }); + }; + }, + ], + actions: { + /** Toggle fullscreen state or explicitly enter/exit based on detail */ + fullscreenrequest: ( + { detail }: Pick, 'detail'> = { detail: undefined }, + ) => { + // If detail is provided, use it; otherwise toggle current state + if (typeof detail === 'boolean') { + return detail; + } + + // Toggle behavior: check current fullscreen state + const fullscreenEl = globalThis?.document?.fullscreenElement; + return !fullscreenEl; + }, + }, + }, +}; diff --git a/packages/core/media-store/src/state-mediators/index.ts b/packages/core/media-store/src/state-mediators/index.ts new file mode 100644 index 00000000..685dfce5 --- /dev/null +++ b/packages/core/media-store/src/state-mediators/index.ts @@ -0,0 +1,4 @@ +export { audible } from './audible'; +export { playable } from './playable'; +export { temporal } from './temporal'; +export { fullscreenable } from './fullscreenable'; \ No newline at end of file