feat(media-store): add fullscreen state mediator with shadow DOM support

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 <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2025-09-12 08:06:08 -07:00
committed by Christian Pillsbury
co-authored by Claude
parent 29ed5e3759
commit 7f243c22d8
4 changed files with 157 additions and 1 deletions
+1
View File
@@ -46,6 +46,7 @@ export type StateMediator = {
currentTime: FacadeProp<HTMLMediaElement['currentTime']>;
duration: ReadonlyFacadeProp<HTMLMediaElement['duration']>;
seekable: ReadonlyFacadeProp<[number, number] | undefined>;
fullscreen: FacadeProp<boolean>;
};
export function createMediaStore({
+2 -1
View File
@@ -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<Parameters<typeof factory>[0]>;
export const createMediaStore = (params: Params = {}) =>
factory({ stateMediator, ...params });
@@ -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<CustomEvent<any>, '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;
},
},
},
};
@@ -0,0 +1,4 @@
export { audible } from './audible';
export { playable } from './playable';
export { temporal } from './temporal';
export { fullscreenable } from './fullscreenable';