import { document, globalThis } from '../utils/server-safe-globals.js'; import { AvailabilityStates, StreamTypes, TextTrackKinds, } from '../constants.js'; import { containsComposedNode } from '../utils/element-utils.js'; import { enterFullscreen, exitFullscreen, isFullscreen, } from '../utils/fullscreen-api.js'; import { airplaySupported, castSupported, fullscreenSupported, hasFullscreenSupport, hasPipSupport, hasVolumeSupportAsync, pipSupported, } from '../utils/platform-tests.js'; import { getShowingSubtitleTracks, getSubtitleTracks, toggleSubtitleTracks, } from './util.js'; import { getTextTracksList } from '../utils/captions.js'; import { isValidNumber } from '../utils/utils.js'; export type Rendition = { src?: string; id?: string; width?: number; height?: number; bitrate?: number; frameRate?: number; codec?: string; readonly selected?: boolean; }; export type AudioTrack = { id?: string; kind?: string; label: string; language: string; enabled: boolean; }; /** * * MediaStateOwner is in a sense both a subset and a superset of `HTMLVideoElement` and is used as the primary * "source of truth" for media state, as well as the primary target for state change requests. * * It is a subset insofar as only the `play()` method, the `paused` property, and the `addEventListener()`/`removeEventListener()` methods * are *required* and required to conform to their definition of `HTMLMediaElement` on the entity used. All other interfaces * (properties, methods, events, etc.) are optional, but, when present, *must* conform to `HTMLMediaElement`/`HTMLVideoElement` * to avoid unexpected state behavior. This includes, for example, ensuring state updates occur *before* related events are fired * that are used to monitor for potential state changes. * * It is a superset insofar as it supports an extended interface for media state that may be browser-specific (e.g. `webkit`-prefixed * properties/methods) or are not immediately derivable from primary media state or other state owners. These include things like * `videoRenditions` for e.g. HTTP Adaptive Streaming media (such as HLS or MPEG-DASH), `audioTracks`, or `streamType`, which identifies * whether the media ("stream") is "live" or "on demand". Several of these are specified and formalized on https://github.com/video-dev/media-ui-extensions. */ export type MediaStateOwner = Partial & Pick< HTMLMediaElement, 'play' | 'paused' | 'addEventListener' | 'removeEventListener' > & { streamType?: StreamTypes; targetLiveWindow?: number; liveEdgeStart?: number; videoRenditions?: Rendition[] & EventTarget & { selectedIndex?: number }; audioTracks?: AudioTrack[] & EventTarget; requestCast?: () => any; webkitDisplayingFullscreen?: boolean; webkitPresentationMode?: 'fullscreen' | 'picture-in-picture'; webkitEnterFullscreen?: () => any; webkitCurrentPlaybackTargetIsWireless?: boolean; webkitShowPlaybackTargetPicker?: () => any; }; export type RootNodeStateOwner = Partial; export type FullScreenElementStateOwner = Partial & EventTarget; export type StateOption = { defaultSubtitles?: boolean; defaultStreamType?: StreamTypes; defaultDuration?: number; liveEdgeOffset?: number; seekToLiveOffset?: number; noAutoSeekToLive?: boolean; noVolumePref?: boolean; noMutedPref?: boolean; noSubtitlesLangPref?: boolean; mediaLang?: string; }; /** * * StateOwners are anything considered a source of truth or a target for updates for state. The media element (or "element") is a source of truth for the state of media playback, * but other things could also be a source of truth for information about the media. These include: * * - media - the media element * - fullscreenElement - the element that will be used when in full screen (e.g. for Media Chrome, this will typically be the MediaController) * - documentElement - top level node for DOM context (usually document and defaults to `document` in `createMediaStore()`) * - options - state behavior/user preferences (e.g. defaultSubtitles to enable subtitles by default as the relevant state or state owners change) */ export type StateOwners = { media?: MediaStateOwner; documentElement?: RootNodeStateOwner; fullscreenElement?: FullScreenElementStateOwner; options?: StateOption; }; export type EventOrAction = { type: string; detail?: D; target?: EventTarget; }; export type FacadeGetter = ( stateOwners: StateOwners, event?: EventOrAction ) => T; export type FacadeSetter = ( value: T, stateOwners: StateOwners, event?: EventOrAction ) => void; export type StateOwnerUpdateHandler = ( handler: (value: T) => void, stateOwners: StateOwners ) => void; export type ReadonlyFacadeProp = { get: FacadeGetter; mediaEvents?: string[]; textTracksEvents?: string[]; videoRenditionsEvents?: string[]; audioTracksEvents?: string[]; remoteEvents?: string[]; rootEvents?: string[]; stateOwnersUpdateHandlers?: StateOwnerUpdateHandler[]; }; export type FacadeProp = ReadonlyFacadeProp & { set: FacadeSetter; }; /** * * StateMediator provides a stateless, well-defined API for getting and setting/updating media-relevant state on a set of (stateful) StateOwners. * In addition, it identifies monitoring conditions for potential state changes for any given bit of state. StateMediator is designed to be used * by a MediaStore, which owns all of the wiring up and persistence of e.g. StateOwners, MediaState, and the StateMediator. * * For any modeled state, the StateMediator defines a key, K, which names the state (e.g. `mediaPaused`, `mediaSubtitlesShowing`, `mediaCastUnavailable`, * etc.), whose value defines the aforementioned using: * * - `get(stateOwners, event)` - Retrieves the current state of K from StateOwners, potentially using the (optional) event to help identify the state. * - `set(value, stateOwners)` (Optional, not available for `Readonly` state) - Interact with StateOwners via their interfaces to (directly or indirectly) update the state of K, using the value to determine the intended state change side effects. * - `mediaEvents[]` (Optional) - An array of event types to monitor on `stateOwners.media` for potential changes in the state of K. * - `textTracksEvents[]` (Optional) - An array of event types to monitor on `stateOwners.media.textTracks` for potential changes in the state of K. * - `videoRenditionsEvents[]` (Optional) - An array of event types to monitor on `stateOwners.media.videoRenditions` for potential changes in the state of K. * - `audioTracksEvents[]` (Optional) - An array of event types to monitor on `stateOwners.media.audioTracks` for potential changes in the state of K. * - `remoteEvents[]` (Optional) - An array of event types to monitor on `stateOwners.media.remote` for potential changes in the state of K. * - `rootEvents[]` (Optional) - An array of event types to monitor on `stateOwners.documentElement` for potential changes in the state of K. * - `stateOwnersUpdateHandlers[]` (Optional) - An array of functions that define arbitrary code for monitoring or causing state changes, optionally returning a "teardown" function for cleanup. * * @example <caption>Basic Example (NOTE: This is for informative use only. StateMediator is not intended to be used directly).</caption> * * // Simple stateOwners example * const stateOwners = { * media: myVideoElement, * fullscreenElement: myMediaUIContainerElement, * documentElement: document, * }; * * // Current mediaPaused state * let mediaPaused = stateMediator.mediaPaused.get(stateOwners); * * // Event handler to update mediaPaused to its latest state; * const updateMediaPausedEventHandler = (event) => { * mediaPaused = stateMediator.mediaPaused.get(stateOwners, event); * }; * * // Monitor for potential changes to mediaPaused state. * stateMediator.mediaPaused.mediaEvents.forEach(eventType => { * stateOwners.media.addEventListener(eventType, updateMediaPausedEventHandler); * }); * * // Function to toggle between mediaPaused and !mediaPaused (media "unpaused", or "playing" under normal conditions) * const toggleMediaPaused = () => { * const nextMediaPaused = !mediaPaused; * stateMediator.mediaPaused.set(nextMediaPaused, stateOwners); * }; * * * // ... Eventual teardown, when relevant. This is especially relevant for potential garbage collection/memory management considerations. * stateMediator.mediaPaused.mediaEvents.forEach(eventType => { * stateOwners.media.removeEventListener(eventType, updateMediaPausedEventHandler); * }); * */ export type StateMediator = { mediaErrorCode: ReadonlyFacadeProp; mediaErrorMessage: ReadonlyFacadeProp; mediaError: ReadonlyFacadeProp; mediaWidth: ReadonlyFacadeProp; mediaHeight: ReadonlyFacadeProp; mediaPaused: FacadeProp; mediaHasPlayed: ReadonlyFacadeProp; mediaEnded: ReadonlyFacadeProp; mediaPlaybackRate: FacadeProp; mediaMuted: FacadeProp; mediaLoop: FacadeProp; mediaVolume: FacadeProp; mediaVolumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>; mediaCurrentTime: FacadeProp; mediaDuration: ReadonlyFacadeProp; mediaLoading: ReadonlyFacadeProp; mediaSeekable: ReadonlyFacadeProp<[number, number] | undefined>; mediaBuffered: ReadonlyFacadeProp<[number, number][]>; mediaStreamType: ReadonlyFacadeProp; mediaTargetLiveWindow: ReadonlyFacadeProp; mediaTimeIsLive: ReadonlyFacadeProp; mediaSubtitlesList: ReadonlyFacadeProp< Pick[] >; mediaSubtitlesShowing: ReadonlyFacadeProp< Pick[] >; mediaChaptersCues: ReadonlyFacadeProp< Pick[] >; mediaIsPip: FacadeProp; mediaRenditionList: ReadonlyFacadeProp; mediaRenditionSelected: FacadeProp; mediaAudioTrackList: ReadonlyFacadeProp<{ id?: string }[]>; mediaAudioTrackEnabled: FacadeProp; mediaIsFullscreen: FacadeProp; mediaIsCasting: FacadeProp< boolean, boolean, 'NO_DEVICES_AVAILABLE' | 'NOT_CONNECTED' | 'CONNECTING' | 'CONNECTED' >; mediaIsAirplaying: FacadeProp; mediaLang: ReadonlyFacadeProp; mediaFullscreenUnavailable: ReadonlyFacadeProp< AvailabilityStates | undefined >; mediaPipUnavailable: ReadonlyFacadeProp; mediaVolumeUnavailable: ReadonlyFacadeProp; mediaCastUnavailable: ReadonlyFacadeProp; mediaAirplayUnavailable: ReadonlyFacadeProp; mediaRenditionUnavailable: ReadonlyFacadeProp; mediaAudioTrackUnavailable: ReadonlyFacadeProp< AvailabilityStates | undefined >; }; const StreamTypeValues = Object.values(StreamTypes); let volumeSupported: boolean; export const volumeSupportPromise: Promise = hasVolumeSupportAsync().then((supported) => { volumeSupported = supported; return volumeSupported; }); export const prepareStateOwners = async ( /** @type {(StateOwners[keyof StateOwners])[]} */ ...stateOwners ) => { await Promise.all( stateOwners .filter((x) => x) .map(async (stateOwner) => { if ( !( 'localName' in stateOwner && stateOwner instanceof globalThis.HTMLElement ) ) { return; } const name = stateOwner.localName; if (!name.includes('-')) return; const classDef = globalThis.customElements.get(name); if (classDef && stateOwner instanceof classDef) return; await globalThis.customElements.whenDefined(name); globalThis.customElements.upgrade(stateOwner); }) ); }; const domParser = new globalThis.DOMParser(); const parseHtmlToText = (text: string) => text ? domParser.parseFromString(text, 'text/html').body.textContent || text : text; export const stateMediator: StateMediator = { mediaError: { get(stateOwners, event) { const { media } = stateOwners; if (event?.type === 'playing') return; // Add additional error info via the `mediaError` element property only. // This can be used in the MediaErrorDialog.formatErrorMessage() method. return media?.error; }, mediaEvents: ['emptied', 'error', 'playing'], }, mediaErrorCode: { get(stateOwners, event) { const { media } = stateOwners; if (event?.type === 'playing') return; return media?.error?.code; }, mediaEvents: ['emptied', 'error', 'playing'], }, mediaErrorMessage: { get(stateOwners, event) { const { media } = stateOwners; if (event?.type === 'playing') return; return media?.error?.message ?? ''; }, mediaEvents: ['emptied', 'error', 'playing'], }, mediaWidth: { get(stateOwners) { const { media } = stateOwners; return media?.videoWidth ?? 0; }, mediaEvents: ['resize'], }, mediaHeight: { get(stateOwners) { const { media } = stateOwners; return media?.videoHeight ?? 0; }, mediaEvents: ['resize'], }, mediaPaused: { get(stateOwners) { const { media } = stateOwners; return media?.paused ?? true; }, set(value, stateOwners) { const { media } = stateOwners; if (!media) return; if (value) { media.pause(); } else { // Not all custom media elements return a promise from `play()`. media.play()?.catch(() => {}); } }, mediaEvents: ['play', 'playing', 'pause', 'emptied'], }, mediaHasPlayed: { // We want to let the user know that the media started playing at any point (`media-has-played`). // Since these propagators are all called when boostrapping state, let's verify this is // a real playing event by checking that 1) there's media and 2) it isn't currently paused. get(stateOwners, event) { const { media } = stateOwners; if (!media) return false; if (!event) return !media.paused; return event.type === 'playing'; }, mediaEvents: ['playing', 'emptied'], }, mediaEnded: { get(stateOwners) { const { media } = stateOwners; return media?.ended ?? false; }, mediaEvents: ['seeked', 'ended', 'emptied'], }, mediaPlaybackRate: { get(stateOwners) { const { media } = stateOwners; return media?.playbackRate ?? 1; }, set(value, stateOwners) { const { media } = stateOwners; if (!media) return; if (!Number.isFinite(+value)) return; media.playbackRate = +value; }, mediaEvents: ['ratechange', 'loadstart'], }, mediaMuted: { get(stateOwners) { const { media } = stateOwners; return media?.muted ?? false; }, set(value, stateOwners) { const { media, options: { noMutedPref } = {} } = stateOwners; if (!media) return; media.muted = value; try { const hasLocalStoragePrefMuted = globalThis.localStorage.getItem('media-chrome-pref-muted') !== null; const hasMutedAttribute = media.hasAttribute('muted'); if (noMutedPref) { // remove stored preference if exists if(hasLocalStoragePrefMuted) globalThis.localStorage.removeItem('media-chrome-pref-muted'); return; } // If muted attribute is present but there's no stored preference, it was probably set manually in the HTML // Don't store preference in localStorage since it will be overridden if (hasMutedAttribute && !hasLocalStoragePrefMuted) { return; } // Store the preference globalThis.localStorage.setItem( 'media-chrome-pref-muted', value ? 'true' : 'false' ); } catch (e) { console.debug('Error setting muted pref', e); } }, mediaEvents: ['volumechange'], stateOwnersUpdateHandlers: [ (handler, stateOwners) => { const { options: { noMutedPref }, } = stateOwners; const { media } = stateOwners; // The muted enabled attribute should still override the preference. if (!media || media.muted || noMutedPref) return; try { const mutedPref = globalThis.localStorage.getItem('media-chrome-pref-muted') === 'true'; stateMediator.mediaMuted.set(mutedPref, stateOwners); handler(mutedPref); } catch (e) { console.debug('Error getting muted pref', e); } }, ], }, mediaLoop: { get(stateOwners) { const { media } = stateOwners; return media?.loop; }, set(value, stateOwners) { const { media } = stateOwners; if (!media) return; media.loop = value; }, mediaEvents: ['medialooprequest'], }, mediaVolume: { get(stateOwners) { const { media } = stateOwners; return media?.volume ?? 1.0; }, set(value, stateOwners) { const { media, options: { noVolumePref } = {} } = stateOwners; if (!media) return; // Store the last set volume as a local preference, if ls is supported /** @TODO How should we handle globalThis dependencies/"state ownership"? (CJP) */ try { if (value == null) { globalThis.localStorage.removeItem('media-chrome-pref-volume'); } else if (!media.hasAttribute('muted') && !noVolumePref) { // Prevent storing volume preference if 'muted' or noVolumePref are present globalThis.localStorage.setItem( 'media-chrome-pref-volume', value.toString() ); } } catch (e) { console.debug('Error setting volume pref', e); } if (!Number.isFinite(+value)) return; media.volume = +value; }, mediaEvents: ['volumechange'], stateOwnersUpdateHandlers: [ (handler, stateOwners) => { const { options: { noVolumePref }, } = stateOwners; if (noVolumePref) return; /** @TODO How should we handle globalThis dependencies/"state ownership"? (CJP) */ try { const { media } = stateOwners; if (!media) return; const volumePref = globalThis.localStorage.getItem( 'media-chrome-pref-volume' ); if (volumePref == null) return; stateMediator.mediaVolume.set(+volumePref, stateOwners); handler(+volumePref); } catch (e) { console.debug('Error getting volume pref', e); } }, ], }, // NOTE: Keeping this roughly equivalent to prior impl to reduce number of changes, // however we may want to model "derived" state differently from "primary" state // (in this case, derived === mediaVolumeLevel, primary === mediaMuted, mediaVolume) (CJP) mediaVolumeLevel: { get(stateOwners) { const { media } = stateOwners; if (typeof media?.volume == 'undefined') return 'high'; if (media.muted || media.volume === 0) return 'off'; if (media.volume < 0.5) return 'low'; if (media.volume < 0.75) return 'medium'; return 'high'; }, mediaEvents: ['volumechange'], }, mediaCurrentTime: { get(stateOwners) { const { media } = stateOwners; return media?.currentTime ?? 0; }, set(value, stateOwners) { const { media } = stateOwners; if (!media || !isValidNumber(value)) return; media.currentTime = value; }, mediaEvents: ['timeupdate', 'loadedmetadata'], }, mediaDuration: { get(stateOwners) { const { media, options: { defaultDuration } = {} } = stateOwners; // If `defaultduration` is set and we don't yet have a usable `duration` // available, use the default duration. if ( defaultDuration && (!media || !media.duration || Number.isNaN(media.duration) || !Number.isFinite(media.duration)) ) { return defaultDuration; } return Number.isFinite(media?.duration) ? media.duration : Number.NaN; }, mediaEvents: ['durationchange', 'loadedmetadata', 'emptied'], }, mediaLoading: { get(stateOwners) { const { media } = stateOwners; return media?.readyState < 3; }, mediaEvents: ['waiting', 'playing', 'emptied'], }, mediaSeekable: { get(stateOwners) { const { media } = stateOwners; if (!media?.seekable?.length) return undefined; const start = media.seekable.start(0); const end = media.seekable.end(media.seekable.length - 1); // Account for cases where metadata from slotted media has an "empty" seekable (CJP) if (!start && !end) return undefined; return [Number(start.toFixed(3)), Number(end.toFixed(3))]; }, mediaEvents: ['loadedmetadata', 'emptied', 'progress', 'seekablechange'], }, mediaBuffered: { get(stateOwners) { const { media } = stateOwners; const timeRanges: any = media?.buffered ?? []; return Array.from(timeRanges).map((_, i) => [ Number(timeRanges.start(i).toFixed(3)), Number(timeRanges.end(i).toFixed(3)), ]); }, mediaEvents: ['progress', 'emptied'], }, mediaStreamType: { get(stateOwners) { const { media, options: { defaultStreamType } = {} } = stateOwners; const usedDefaultStreamType = [ StreamTypes.LIVE, StreamTypes.ON_DEMAND, ].includes(defaultStreamType as any) ? defaultStreamType : undefined; if (!media) return usedDefaultStreamType; const { streamType } = media; if (StreamTypeValues.includes(streamType)) { // If the slotted media supports `streamType` but // `streamType` is "unknown", prefer `usedDefaultStreamType` // if set (CJP) if (streamType === StreamTypes.UNKNOWN) { return usedDefaultStreamType; } return streamType; } const duration = media.duration; if (duration === Infinity) { return StreamTypes.LIVE; } else if (Number.isFinite(duration)) { return StreamTypes.ON_DEMAND; } return usedDefaultStreamType; }, mediaEvents: [ 'emptied', 'durationchange', 'loadedmetadata', 'streamtypechange', ], }, mediaTargetLiveWindow: { get(stateOwners) { const { media } = stateOwners; if (!media) return Number.NaN; const { targetLiveWindow } = media; const streamType = stateMediator.mediaStreamType.get(stateOwners); // Since `NaN` represents either "unknown" or "inapplicable", need to check if `streamType` // is `"live"`. If so, assume it's "standard live" (aka `targetLiveWindow === 0`) (CJP) if ( (targetLiveWindow == null || Number.isNaN(targetLiveWindow)) && streamType === StreamTypes.LIVE ) { return 0; } return targetLiveWindow; }, mediaEvents: [ 'emptied', 'durationchange', 'loadedmetadata', 'streamtypechange', 'targetlivewindowchange', ], }, mediaTimeIsLive: { get(stateOwners) { const { media, // Default to 10 seconds options: { liveEdgeOffset = 10 } = {}, } = stateOwners; if (!media) return false; if (typeof media.liveEdgeStart === 'number') { if (Number.isNaN(media.liveEdgeStart)) return false; return media.currentTime >= media.liveEdgeStart; } const live = stateMediator.mediaStreamType.get(stateOwners) === StreamTypes.LIVE; // Can't be playing live if it's not a live stream if (!live) return false; // Should this use `stateMediator.mediaSeekable.get(stateOwners)?.[1]` for separation // of concerns/assumptions? (CJP) const seekable = media.seekable; // If the slotted media element is live but does not expose a 'seekable' `TimeRanges` object, // always assume playing live if (!seekable) return true; // If there is an empty `seekable`, assume we are not playing live if (!seekable.length) return false; const liveEdgeStart = seekable.end(seekable.length - 1) - liveEdgeOffset; return media.currentTime >= liveEdgeStart; }, mediaEvents: ['playing', 'timeupdate', 'progress', 'waiting', 'emptied'], }, // Text Tracks modeling mediaSubtitlesList: { get(stateOwners) { return getSubtitleTracks(stateOwners).map( ({ kind, label, language }) => ({ kind, label, language }) ); }, mediaEvents: ['loadstart'], textTracksEvents: ['addtrack', 'removetrack'], }, mediaSubtitlesShowing: { get(stateOwners) { return getShowingSubtitleTracks(stateOwners).map( ({ kind, label, language }) => ({ kind, label, language }) ); }, mediaEvents: ['loadstart'], textTracksEvents: ['addtrack', 'removetrack', 'change'], stateOwnersUpdateHandlers: [ (_handler, stateOwners) => { const { media, options } = stateOwners; if (!media) return; const updateDefaultSubtitlesCallback = (event?: Event) => { if (!options.defaultSubtitles) return; const nonSubsEvent = event && ![TextTrackKinds.CAPTIONS, TextTrackKinds.SUBTITLES].includes( // @ts-ignore event?.track?.kind ); if (nonSubsEvent) return; // NOTE: In this use case, since we're causing a side effect, no need to invoke `handler()`. (CJP) toggleSubtitleTracks(stateOwners, true); }; media.addEventListener( 'loadstart', updateDefaultSubtitlesCallback ); media.textTracks?.addEventListener( 'addtrack', updateDefaultSubtitlesCallback ); media.textTracks?.addEventListener( 'removetrack', updateDefaultSubtitlesCallback ); return () => { media.removeEventListener( 'loadstart', updateDefaultSubtitlesCallback ); media.textTracks?.removeEventListener( 'addtrack', updateDefaultSubtitlesCallback ); media.textTracks?.removeEventListener( 'removetrack', updateDefaultSubtitlesCallback ); }; }, ], }, mediaChaptersCues: { get(stateOwners: StateOwners) { const { media } = stateOwners; if (!media) return []; const [chaptersTrack] = getTextTracksList(media as HTMLVideoElement, { kind: TextTrackKinds.CHAPTERS, }); return Array.from(chaptersTrack?.cues ?? []).map( ({ text, startTime, endTime }: VTTCue) => ({ text: parseHtmlToText(text), startTime, endTime, }) ); }, mediaEvents: ['loadstart', 'loadedmetadata'], textTracksEvents: ['addtrack', 'removetrack', 'change'], stateOwnersUpdateHandlers: [ (handler, stateOwners) => { const { media } = stateOwners; if (!media) return; /** @TODO account for adds/removes/replacements of (CJP) */ const chaptersTrack = media.querySelector( 'track[kind="chapters"][default][src]' ); /* If `media` is a custom media element search in its shadow DOM. */ const shadowChaptersTrack = media.shadowRoot?.querySelector( ':is(video,audio) > track[kind="chapters"][default][src]' ); /** @ts-ignore */ chaptersTrack?.addEventListener('load', handler); /** @ts-ignore */ shadowChaptersTrack?.addEventListener('load', handler); return () => { /** @ts-ignore */ chaptersTrack?.removeEventListener('load', handler); /** @ts-ignore */ shadowChaptersTrack?.removeEventListener('load', handler); }; }, ], }, // Modeling state tied to root node mediaIsPip: { get(stateOwners) { const { media, documentElement } = stateOwners; // Need a documentElement and a media StateOwner to be in PiP, so we're not PiP if (!media || !documentElement) return false; // Need a documentElement.pictureInPictureElement to be in PiP, so we're not PiP if (!documentElement.pictureInPictureElement) return false; // If documentElement.pictureInPictureElement is the media StateOwner, we're definitely in PiP if (documentElement.pictureInPictureElement === media) return true; // In this case (e.g. Safari), the pictureInPictureElement may be // the underlying