Skip to content

Commit

Permalink
fix: Larger refactor for fullscreen based on discussion with Wes.
Browse files Browse the repository at this point in the history
  • Loading branch information
cjpillsbury committed Jul 25, 2024
1 parent 610b0d3 commit 9444718
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 130 deletions.
99 changes: 8 additions & 91 deletions src/js/media-store/state-mediator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import {
AvailabilityStates,
StreamTypes,
TextTrackKinds,
WebkitPresentationModes,
} from '../constants.js';
import { containsComposedNode } from '../utils/element-utils.js';
import { fullscreenApi } from '../utils/fullscreen-api.js';
import { enterFullscreen, exitFullscreen, isFullscreen } from '../utils/fullscreen-api.js';
import {
airplaySupported,
castSupported,
Expand Down Expand Up @@ -889,100 +888,18 @@ export const stateMediator = {
},
mediaIsFullscreen: {
get(stateOwners) {
const { media, documentElement, fullscreenElement = media } = stateOwners;

// Need a documentElement and a media StateOwner to be in fullscreen, so we're not fullscreen
if (!media || !documentElement) return false;

// Need a documentElement.fullscreenElement to be in fullscreen, so we're not fullscreen
if (!documentElement[fullscreenApi.element]) {
// Except for iOS, which doesn't conform to the standard API
// See: https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1630493-webkitdisplayingfullscreen
if (
'webkitDisplayingFullscreen' in media &&
'webkitPresentationMode' in media
) {
// Unfortunately, webkitDisplayingFullscreen is also true when in PiP, so we also check if webkitPresentationMode is 'fullscreen'.
return (
media.webkitDisplayingFullscreen &&
media.webkitPresentationMode === WebkitPresentationModes.FULLSCREEN
);
}
return false;
}

// If documentElement.fullscreenElement is the media StateOwner, we're definitely in fullscreen
if (documentElement[fullscreenApi.element] === fullscreenElement)
return true;

// In this case (most modern browsers, sans e.g. iOS), the fullscreenElement may be
// a web component that is "visible" from the documentElement, but should
// have its own fullscreenElement on its shadowRoot for whatever
// is "visible" at that level. Since the (also named) fullscreenElement StateOwner
// may be nested inside an indeterminite number of web components, traverse each layer
// until we either find the fullscreen StateOwner or complete the recursive check.
if (documentElement[fullscreenApi.element].localName.includes('-')) {
let currentRoot = documentElement[fullscreenApi.element].shadowRoot;

// NOTE: This is for (non-iOS) Safari < 16.4, which did not support ShadowRoot::fullscreenElement.
// We can remove this if/when we decide those versions are old enough/not used enough to handle
// (e.g. at the time of writing, < 16.4 ~= 1% of global market, per caniuse https://caniuse.com/mdn-api_shadowroot_fullscreenelement) (CJP)

// We can simply check if the fullscreenElement key (typically 'fullscreenElement') is defined on the shadowRoot to determine whether or not
// it is supported.
if (!(fullscreenApi.element in currentRoot)) {
// For these cases, if documentElement.fullscreenElement (aka document.fullscreenElement) contains our fullscreenElement StateOwner,
// we'll assume that means we're in fullscreen. That should be valid for all current actual and planned supported
// web component use cases.
return containsComposedNode(
documentElement[fullscreenApi.element],
/** @TODO clean up type assumptions (e.g. Node) (CJP) */
// @ts-ignore
fullscreenElement
);
}

while (currentRoot?.[fullscreenApi.element]) {
if (currentRoot[fullscreenApi.element] === fullscreenElement)
return true;
currentRoot = currentRoot[fullscreenApi.element]?.shadowRoot;
}
}

return false;
return isFullscreen(stateOwners);
},
set(value, stateOwners) {
const { media, fullscreenElement, documentElement } = stateOwners;

// Exiting fullscreen case (generic)
if (!value && documentElement?.[fullscreenApi.exit]) {
const maybePromise = documentElement?.[fullscreenApi.exit]?.();
// NOTE: Since the "official" exit fullscreen method yields a Promise that rejects
// if not in fullscreen, this accounts for those cases.
if (maybePromise instanceof Promise) {
maybePromise.catch(() => {});
}
return;
}

// Entering fullscreen cases (browser-specific)
if (fullscreenElement?.[fullscreenApi.enter]) {
// NOTE: Since the "official" enter fullscreen method yields a Promise that rejects
// if already in fullscreen, this accounts for those cases.
const maybePromise = fullscreenElement[fullscreenApi.enter]?.();
if (maybePromise instanceof Promise) {
maybePromise.catch(() => {});
}
} else if (media?.webkitEnterFullscreen) {
// Media element fullscreen using iOS API
media.webkitEnterFullscreen();
} else if (media?.requestFullscreen) {
// So media els don't have to implement multiple APIs.
media.requestFullscreen();
if (!value) {
exitFullscreen(stateOwners);
} else {
enterFullscreen(stateOwners);
}
},
// older Safari version may require webkit-specific events
rootEvents: ['fullscreenchange', 'webkitfullscreenchange'],
// iOS requires `webkitbeginfullscreen` and `webkitendfullscreen` events on the video.
// iOS requires webkit-specific events on the video.
mediaEvents: ['webkitbeginfullscreen', 'webkitendfullscreen', 'webkitpresentationmodechanged']
},
mediaIsCasting: {
Expand Down
205 changes: 174 additions & 31 deletions src/js/utils/fullscreen-api.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,179 @@
import { WebkitPresentationModes } from '../constants.js';
import { containsComposedNode } from './element-utils.js';
import { document } from './server-safe-globals.js';

export const fullscreenApi = {
enter:
'requestFullscreen' in document
// NOTE: (re)defining these types, but more narrowly for API expectations. These should probably be centralized + derived
// once migrated to TypeScript types (CJP)

/**
* @typedef {Partial<HTMLVideoElement> & {
* webkitDisplayingFullscreen?: boolean;
* webkitPresentationMode?: 'fullscreen'|'picture-in-picture';
* webkitEnterFullscreen?: () => any;
* }} MediaStateOwner
*/

/**
* @typedef {Partial<Document|ShadowRoot>} RootNodeStateOwner
*/

/**
* @typedef {Partial<HTMLElement>} FullScreenElementStateOwner
*/

/**
* @typedef {object} StateOwners
* @property {MediaStateOwner} [media]
* @property {RootNodeStateOwner} [documentElement]
* @property {FullScreenElementStateOwner} [fullscreenElement]
*/

/** @type {(stateOwners: StateOwners) => Promise<undefined> | undefined} */
export const enterFullscreen = (stateOwners) => {
const { media, fullscreenElement } = stateOwners;

// NOTE: Since the fullscreenElement can change and may be a web component,
// we should not define this at the module level. As an optimization,
// we could only define/update this somehow based on state owner changes. (CJP)
const enterFullscreenKey =
fullscreenElement && 'requestFullscreen' in fullscreenElement
? 'requestFullscreen'
: 'webkitRequestFullScreen' in document
: fullscreenElement && 'webkitRequestFullScreen' in fullscreenElement
? 'webkitRequestFullScreen'
: undefined,
exit:
'exitFullscreen' in document
? 'exitFullscreen'
: 'webkitExitFullscreen' in document
? 'webkitExitFullscreen'
: 'webkitCancelFullScreen' in document
? 'webkitCancelFullScreen'
: undefined,
element:
'fullscreenElement' in document
? 'fullscreenElement'
: 'webkitFullscreenElement' in document
? 'webkitFullscreenElement'
: undefined,
error:
'fullscreenerror' in document
? 'fullscreenerror'
: 'webkitfullscreenerror' in document
? 'webkitfullscreenerror'
: undefined,
enabled:
'fullscreenEnabled' in document
? 'fullscreenEnabled'
: 'webkitFullscreenEnabled' in document
? 'webkitFullscreenEnabled'
: undefined,
: undefined;

// Entering fullscreen cases (browser-specific)
if (enterFullscreenKey) {
// NOTE: Since the "official" enter fullscreen method yields a Promise that rejects
// if already in fullscreen, this accounts for those cases.
const maybePromise = fullscreenElement[enterFullscreenKey]?.();
if (maybePromise instanceof Promise) {
return maybePromise.catch(() => {});
}
} else if (media?.webkitEnterFullscreen) {
// Media element fullscreen using iOS API
media.webkitEnterFullscreen();
} else if (media?.requestFullscreen) {
// So media els don't have to implement multiple APIs.
media.requestFullscreen();
}
};

const exitFullscreenKey =
'exitFullscreen' in document
? 'exitFullscreen'
: 'webkitExitFullscreen' in document
? 'webkitExitFullscreen'
: 'webkitCancelFullScreen' in document
? 'webkitCancelFullScreen'
: undefined;

/** @type {(stateOwners: StateOwners) => Promise<undefined> | undefined} */
export const exitFullscreen = (stateOwners) => {
const { documentElement } = stateOwners;

// Exiting fullscreen case (generic)
if (exitFullscreenKey) {
const maybePromise = documentElement?.[exitFullscreenKey]?.();
// NOTE: Since the "official" exit fullscreen method yields a Promise that rejects
// if not in fullscreen, this accounts for those cases.
if (maybePromise instanceof Promise) {
return maybePromise.catch(() => {});
}
}
};

const fullscreenElementKey =
'fullscreenElement' in document
? 'fullscreenElement'
: 'webkitFullscreenElement' in document
? 'webkitFullscreenElement'
: undefined;

/** @type {(stateOwners: StateOwners) => FullScreenElementStateOwner | null | undefined} */
export const getFullscreenElement = (stateOwners) => {
const { documentElement, media } = stateOwners;
const docFullscreenElement = documentElement?.[fullscreenElementKey];
if (
!docFullscreenElement &&
'webkitDisplayingFullscreen' in media &&
'webkitPresentationMode' in media &&
media.webkitDisplayingFullscreen &&
media.webkitPresentationMode === WebkitPresentationModes.FULLSCREEN
) {
return media;
}
return docFullscreenElement;
};

/** @type {(stateOwners: StateOwners) => boolean} */
export const isFullscreen = (stateOwners) => {
const { media, documentElement, fullscreenElement = media } = stateOwners;

// Need a documentElement and a media StateOwner to be in fullscreen, so we're not fullscreen
if (!media || !documentElement) return false;

const currentFullscreenElement = getFullscreenElement(stateOwners);

// If there is no current fullscreenElement, we're definitely not in fullscreen.
if (!currentFullscreenElement) return false;

// If documentElement.fullscreenElement is the media or fullscreenElement StateOwner, we're definitely in fullscreen
if (
currentFullscreenElement === fullscreenElement ||
currentFullscreenElement === media
) {
return true;
}

// In this case (most modern browsers, sans e.g. iOS), the fullscreenElement may be
// a web component that is "visible" from the documentElement, but should
// have its own fullscreenElement on its shadowRoot for whatever
// is "visible" at that level. Since the (also named) fullscreenElement StateOwner
// may be nested inside an indeterminite number of web components, traverse each layer
// until we either find the fullscreen StateOwner or complete the recursive check.
if (currentFullscreenElement.localName.includes('-')) {
let currentRoot = currentFullscreenElement.shadowRoot;

// NOTE: This is for (non-iOS) Safari < 16.4, which did not support ShadowRoot::fullscreenElement.
// We can remove this if/when we decide those versions are old enough/not used enough to handle
// (e.g. at the time of writing, < 16.4 ~= 1% of global market, per caniuse https://caniuse.com/mdn-api_shadowroot_fullscreenelement) (CJP)

// We can simply check if the fullscreenElement key (typically 'fullscreenElement') is defined on the shadowRoot to determine whether or not
// it is supported.
if (!(fullscreenElementKey in currentRoot)) {
// For these cases, if documentElement.fullscreenElement (aka document.fullscreenElement) contains our fullscreenElement StateOwner,
// we'll assume that means we're in fullscreen. That should be valid for all current actual and planned supported
// web component use cases.
return containsComposedNode(
currentFullscreenElement,
/** @TODO clean up type assumptions (e.g. Node) (CJP) */
// @ts-ignore
fullscreenElement
);
}

while (currentRoot?.[fullscreenElementKey]) {
if (currentRoot[fullscreenElementKey] === fullscreenElement) return true;
currentRoot = currentRoot[fullscreenElementKey]?.shadowRoot;
}
}

return false;
};

const fullscreenEnabledKey =
'fullscreenEnabled' in document
? 'fullscreenEnabled'
: 'webkitFullscreenEnabled' in document
? 'webkitFullscreenEnabled'
: undefined;

/** @type {(stateOwners: StateOwners) => boolean} */
export const isFullscreenEnabled = (stateOwners) => {
const { documentElement, media } = stateOwners;
return (
!!documentElement?.[fullscreenEnabledKey] ||
(media && 'webkitSupportsFullscreen' in media)
);
};
10 changes: 2 additions & 8 deletions src/js/utils/platform-tests.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { globalThis, document } from './server-safe-globals.js';
import { delay } from './utils.js';
import { fullscreenApi } from './fullscreen-api.js';
import { isFullscreenEnabled } from './fullscreen-api.js';

/**
* Test element
Expand Down Expand Up @@ -58,13 +58,7 @@ export const hasPipSupport = (mediaEl = getTestMediaEl()) => {
* @returns {boolean}
*/
export const hasFullscreenSupport = (mediaEl = getTestMediaEl()) => {
let fullscreenEnabled = document[fullscreenApi.enabled];

if (!fullscreenEnabled && mediaEl) {
fullscreenEnabled = 'webkitSupportsFullscreen' in mediaEl;
}

return fullscreenEnabled;
return isFullscreenEnabled({ documentElement: document, media: mediaEl });
};

export const fullscreenSupported = hasFullscreenSupport();
Expand Down

0 comments on commit 9444718

Please sign in to comment.