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