UNPKG

media-chrome

Version:

Custom elements (web components) for making audio and video player controls that look great in your website or app.

1,231 lines (1,134 loc) • 42.9 kB
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<HTMLVideoElement> & 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<Document | ShadowRoot>; export type FullScreenElementStateOwner = Partial<HTMLElement> & EventTarget; export type StateOption = { defaultSubtitles?: boolean; defaultStreamType?: StreamTypes; defaultDuration?: number; liveEdgeOffset?: number; seekToLiveOffset?: number; noAutoSeekToLive?: boolean; noVolumePref?: boolean; noMutedPref?: boolean; noSubtitlesLangPref?: boolean; }; /** * * 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<D = undefined> = { type: string; detail?: D; target?: EventTarget; }; export type FacadeGetter<T, D = T> = ( stateOwners: StateOwners, event?: EventOrAction<D> ) => T; export type FacadeSetter<T> = (value: T, stateOwners: StateOwners) => void; export type StateOwnerUpdateHandler<T> = ( handler: (value: T) => void, stateOwners: StateOwners ) => void; export type ReadonlyFacadeProp<T, D = T> = { get: FacadeGetter<T, D>; mediaEvents?: string[]; textTracksEvents?: string[]; videoRenditionsEvents?: string[]; audioTracksEvents?: string[]; remoteEvents?: string[]; rootEvents?: string[]; stateOwnersUpdateHandlers?: StateOwnerUpdateHandler<T>[]; }; export type FacadeProp<T, S = T, D = T> = ReadonlyFacadeProp<T, D> & { set: FacadeSetter<S>; }; /** * * 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 &lt;caption>Basic Example (NOTE: This is for informative use only. StateMediator is not intended to be used directly).&lt;/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<MediaError['code']>; mediaErrorMessage: ReadonlyFacadeProp<MediaError['message']>; mediaError: ReadonlyFacadeProp<MediaError>; mediaWidth: ReadonlyFacadeProp<number>; mediaHeight: ReadonlyFacadeProp<number>; mediaPaused: FacadeProp<HTMLMediaElement['paused']>; mediaHasPlayed: ReadonlyFacadeProp<boolean>; mediaEnded: ReadonlyFacadeProp<HTMLMediaElement['ended']>; mediaPlaybackRate: FacadeProp<HTMLMediaElement['playbackRate']>; mediaMuted: FacadeProp<HTMLMediaElement['muted']>; mediaVolume: FacadeProp<HTMLMediaElement['volume']>; mediaVolumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>; mediaCurrentTime: FacadeProp<HTMLMediaElement['currentTime']>; mediaDuration: ReadonlyFacadeProp<HTMLMediaElement['duration']>; mediaLoading: ReadonlyFacadeProp<boolean>; mediaSeekable: ReadonlyFacadeProp<[number, number] | undefined>; mediaBuffered: ReadonlyFacadeProp<[number, number][]>; mediaStreamType: ReadonlyFacadeProp<StreamTypes>; mediaTargetLiveWindow: ReadonlyFacadeProp<number>; mediaTimeIsLive: ReadonlyFacadeProp<boolean>; mediaSubtitlesList: ReadonlyFacadeProp< Pick<TextTrack, 'kind' | 'label' | 'language'>[] >; mediaSubtitlesShowing: ReadonlyFacadeProp< Pick<TextTrack, 'kind' | 'label' | 'language'>[] >; mediaChaptersCues: ReadonlyFacadeProp< Pick<VTTCue, 'text' | 'startTime' | 'endTime'>[] >; mediaIsPip: FacadeProp<boolean>; mediaRenditionList: ReadonlyFacadeProp<Rendition[]>; mediaRenditionSelected: FacadeProp<string, string>; mediaAudioTrackList: ReadonlyFacadeProp<{ id?: string }[]>; mediaAudioTrackEnabled: FacadeProp<string, string>; mediaIsFullscreen: FacadeProp<boolean>; mediaIsCasting: FacadeProp< boolean, boolean, 'NO_DEVICES_AVAILABLE' | 'NOT_CONNECTED' | 'CONNECTING' | 'CONNECTED' >; mediaIsAirplaying: FacadeProp<boolean>; mediaFullscreenUnavailable: ReadonlyFacadeProp< AvailabilityStates | undefined >; mediaPipUnavailable: ReadonlyFacadeProp<AvailabilityStates | undefined>; mediaVolumeUnavailable: ReadonlyFacadeProp<AvailabilityStates | undefined>; mediaCastUnavailable: ReadonlyFacadeProp<AvailabilityStates | undefined>; mediaAirplayUnavailable: ReadonlyFacadeProp<AvailabilityStates | undefined>; mediaRenditionUnavailable: ReadonlyFacadeProp<AvailabilityStates | undefined>; mediaAudioTrackUnavailable: ReadonlyFacadeProp< AvailabilityStates | undefined >; }; const StreamTypeValues = Object.values(StreamTypes); let volumeSupported: boolean; export const volumeSupportPromise: Promise<boolean> = 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); }) ); }; 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 } = stateOwners; if (!media) return; try { globalThis.localStorage.setItem( 'media-chrome-pref-muted', value ? 'true' : 'false' ); } catch (e) { console.debug('Error setting muted pref', e); } media.muted = value; }, 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); } }, ], }, mediaVolume: { get(stateOwners) { const { media } = stateOwners; return media?.volume ?? 1.0; }, set(value, stateOwners) { const { media } = 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 { 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, 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 <track> (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 <video> or <audio> element of a media StateOwner // that is a web component, even if it's not "visible" from the // documentElement, so check for that. if (documentElement.pictureInPictureElement instanceof HTMLMediaElement) { if (!media.localName?.includes('-')) return false; return containsComposedNode( media as Node, documentElement.pictureInPictureElement ); } // In this case (e.g. Chrome), the pictureInPictureElement may be // a web component that is "visible" from the documentElement, but should // have its own pictureInPictureElement on its shadowRoot for whatever // is "visible" at that level. Since the media StateOwner may be nested // inside an indeterminite number of web components, traverse each layer // until we either find the media StateOwner or complete the recursive check. if (documentElement.pictureInPictureElement.localName.includes('-')) { let currentRoot = documentElement.pictureInPictureElement.shadowRoot; while (currentRoot?.pictureInPictureElement) { if (currentRoot.pictureInPictureElement === media) return true; currentRoot = currentRoot.pictureInPictureElement?.shadowRoot; } } return false; }, set(value, stateOwners) { const { media } = stateOwners; if (!media) return; if (value) { if (!document.pictureInPictureEnabled) { console.warn('MediaChrome: Picture-in-picture is not enabled'); // Placeholder for emitting a user-facing warning return; } if (!media.requestPictureInPicture) { console.warn( 'MediaChrome: The current media does not support picture-in-picture' ); // Placeholder for emitting a user-facing warning return; } const warnNotReady = () => { console.warn( 'MediaChrome: The media is not ready for picture-in-picture. It must have a readyState > 0.' ); }; // Should be async media.requestPictureInPicture().catch((err) => { // InvalidStateError, readyState == 0 (Not ready) if (err.code === 11) { if (!media.src) { console.warn( 'MediaChrome: The media is not ready for picture-in-picture. It must have a src set.' ); return; } // We can assume the viewer wants the video to load, so attempt to // if we can rely on readyState and preload // Only works in Chrome currently. Safari doesn't allow triggering // in an event listener. Also requires readyState == 4. // Firefox doesn't have the PiP API yet. if (media.readyState === 0 && media.preload === 'none') { const cleanup = () => { media.removeEventListener('loadedmetadata', tryPip); media.preload = 'none'; }; const tryPip = () => { media.requestPictureInPicture().catch(warnNotReady); cleanup(); }; media.addEventListener('loadedmetadata', tryPip); media.preload = 'metadata'; // No easy way to know if this failed and we should clean up // quickly if it doesn't to prevent an awkward delay for the user setTimeout(() => { if (media.readyState === 0) warnNotReady(); cleanup(); }, 1000); } else { /** @TODO Should we actually rethrow? Feels like something we could log instead for improved devex (CJP) */ // Rethrow if unknown context throw err; } } else { /** @TODO Should we actually rethrow? Feels like something we could log instead for improved devex (CJP) */ // Rethrow if unknown context throw err; } }); } else if (document.pictureInPictureElement) { document.exitPictureInPicture(); } }, mediaEvents: ['enterpictureinpicture', 'leavepictureinpicture'], }, mediaRenditionList: { get(stateOwners) { const { media } = stateOwners; // NOTE: Copying for reference considerations (should be an array of POJOs from a state perspective) (CJP) return [...(media?.videoRenditions ?? [])].map((videoRendition) => ({ ...videoRendition, })); }, mediaEvents: ['emptied', 'loadstart'], videoRenditionsEvents: ['addrendition', 'removerendition'], }, /** @TODO Model this as a derived value? (CJP) */ mediaRenditionSelected: { get(stateOwners) { const { media } = stateOwners; return media?.videoRenditions?.[media.videoRenditions?.selectedIndex]?.id; }, set(value, stateOwners) { const { media } = stateOwners; if (!media?.videoRenditions) { console.warn( 'MediaController: Rendition selection not supported by this media.' ); return; } const renditionId = value; // NOTE: videoRenditions is an array-like, not an array (CJP) const index = Array.prototype.findIndex.call( media.videoRenditions, (r) => r.id == renditionId ); if (media.videoRenditions.selectedIndex != index) { media.videoRenditions.selectedIndex = index; } }, mediaEvents: ['emptied'], videoRenditionsEvents: ['addrendition', 'removerendition', 'change'], }, mediaAudioTrackList: { get(stateOwners) { const { media } = stateOwners; return [...(media?.audioTracks ?? [])]; }, mediaEvents: ['emptied', 'loadstart'], audioTracksEvents: ['addtrack', 'removetrack'], }, mediaAudioTrackEnabled: { get(stateOwners) { const { media } = stateOwners; return [...(media?.audioTracks ?? [])].find( (audioTrack) => audioTrack.enabled )?.id; }, set(value, stateOwners) { const { media } = stateOwners; if (!media?.audioTracks) { console.warn( 'MediaChrome: Audio track selection not supported by this media.' ); return; } const audioTrackId = value; for (const track of media.audioTracks) { track.enabled = audioTrackId == track.id; } }, mediaEvents: ['emptied'], audioTracksEvents: ['addtrack', 'removetrack', 'change'], }, mediaIsFullscreen: { get(stateOwners) { return isFullscreen(stateOwners); }, set(value, stateOwners) { if (!value) { exitFullscreen(stateOwners); } else { enterFullscreen(stateOwners); } }, // older Safari version may require webkit-specific events rootEvents: ['fullscreenchange', 'webkitfullscreenchange'], // iOS requires webkit-specific events on the video. mediaEvents: [ 'webkitbeginfullscreen', 'webkitendfullscreen', 'webkitpresentationmodechanged', ], }, mediaIsCasting: { // Note this relies on a customized castable-video element. get(stateOwners) { const { media } = stateOwners; if (!media?.remote || media.remote?.state === 'disconnected') return false; return !!media.remote.state; }, set(value, stateOwners) { const { media } = stateOwners; if (!media) return; if (value && media.remote?.state !== 'disconnected') return; if (!value && media.remote?.state !== 'connected') return; if (typeof media.remote.prompt !== 'function') { console.warn( 'MediaChrome: Casting is not supported in this environment' ); return; } // Open the browser cast menu. // Note this relies on a customized castable-video element. media.remote .prompt() // Don't warn here because catch is run when the user closes the cast menu. .catch(() => {}); }, remoteEvents: ['connect', 'connecting', 'disconnect'], }, // NOTE: Newly added state for tracking airplaying mediaIsAirplaying: { // NOTE: Cannot know if airplaying since Safari doesn't fully support HTMLMediaElement::remote yet (e.g. remote::state) (CJP) get() { return false; }, set(_value, stateOwners) { const { media } = stateOwners; if (!media) return; if ( !( media.webkitShowPlaybackTargetPicker && globalThis.WebKitPlaybackTargetAvailabilityEvent ) ) { console.warn( 'MediaChrome: received a request to select AirPlay but AirPlay is not supported in this environment' ); return; } media.webkitShowPlaybackTargetPicker(); }, mediaEvents: ['webkitcurrentplaybacktargetiswirelesschanged'], }, mediaFullscreenUnavailable: { get(stateOwners) { const { media } = stateOwners; if ( !fullscreenSupported || !hasFullscreenSupport(media as HTMLVideoElement) ) return AvailabilityStates.UNSUPPORTED; return undefined; }, }, mediaPipUnavailable: { get(stateOwners) { const { media } = stateOwners; if (!pipSupported || !hasPipSupport(media as HTMLVideoElement)) return AvailabilityStates.UNSUPPORTED; }, }, mediaVolumeUnavailable: { get(stateOwners) { const { media } = stateOwners; if (volumeSupported === false || media?.volume == undefined) { return AvailabilityStates.UNSUPPORTED; } return undefined; }, // NOTE: Slightly different impl here. Added generic support for // "stateOwnersUpdateHandlers" since the original impl had to hack around // race conditions. (CJP) stateOwnersUpdateHandlers: [ (handler) => { if (volumeSupported == null) { volumeSupportPromise.then((supported) => handler(supported ? undefined : AvailabilityStates.UNSUPPORTED) ); } }, ], }, mediaCastUnavailable: { // @ts-ignore get(stateOwners, { availability = 'not-available' } = {}) { const { media } = stateOwners; if (!castSupported || !media?.remote?.state) { return AvailabilityStates.UNSUPPORTED; } if (availability == null || availability === 'available') return undefined; return AvailabilityStates.UNAVAILABLE; }, stateOwnersUpdateHandlers: [ (handler, stateOwners) => { const { media } = stateOwners; if (!media) return; const remotePlaybackDisabled = media.disableRemotePlayback || media.hasAttribute('disableremoteplayback'); if (!remotePlaybackDisabled) { media?.remote ?.watchAvailability((availabilityBool) => { // Normalizing to `webkitplaybacktargetavailabilitychanged` for consistency. const availability = availabilityBool ? 'available' : 'not-available'; // @ts-ignore handler({ availability }); }) .catch((error) => { if (error.name === 'NotSupportedError') { // Availability monitoring is not supported by the platform, so discovery of // remote playback devices will happen only after remote.prompt() is called. // @ts-ignore handler({ availability: null }); } else { // Thrown if disableRemotePlayback is true for the media element // or if the source can't be played remotely. // Normalizing to `webkitplaybacktargetavailabilitychanged` for consistency. // @ts-ignore handler({ availability: 'not-available' }); } }); } return () => { media?.remote?.cancelWatchAvailability().catch(() => {}); }; }, ], }, mediaAirplayUnavailable: { get(_stateOwners, event) { if (!airplaySupported) return AvailabilityStates.UNSUPPORTED; // @ts-ignore if (event?.availability === 'not-available') { return AvailabilityStates.UNAVAILABLE; } // Either available via `availability` state or not yet known return undefined; }, // NOTE: Keeping this event, as it's still the documented way of monitoring // for AirPlay availability from Apple. // See: https://developer.apple.com/documentation/webkitjs/adding_an_airplay_button_to_your_safari_media_controls#2940021 (CJP) mediaEvents: ['webkitplaybacktargetavailabilitychanged'], stateOwnersUpdateHandlers: [ (handler, stateOwners) => { const { media } = stateOwners; if (!media) return; const remotePlaybackDisabled = media.disableRemotePlayback || media.hasAttribute('disableremoteplayback'); if (!remotePlaybackDisabled) { media?.remote ?.watchAvailability((availabilityBool) => { // Normalizing to `webkitplaybacktargetavailabilitychanged` for consistency. const availability = availabilityBool ? 'available' : 'not-available'; // @ts-ignore handler({ availability }); }) .catch((error) => { if (error.name === 'NotSupportedError') { // Availability monitoring is not supported by the platform, so discovery of // remote playback devices will happen only after remote.prompt() is called. // @ts-ignore handler({ availability: null }); } else { // Thrown if disableRemotePlayback is true for the media element // or if the source can't be played remotely. // Normalizing to `webkitplaybacktargetavailabilitychanged` for consistency. // @ts-ignore handler({ availability: 'not-available' }); } }); } return () => { media?.remote?.cancelWatchAvailability().catch(() => {}); }; }, ], }, mediaRenditionUnavailable: { get(stateOwners) { const { media } = stateOwners; if (!media?.videoRenditions) { return AvailabilityStates.UNSUPPORTED; } if (!media.videoRenditions?.length) { return AvailabilityStates.UNAVAILABLE; } return undefined; }, mediaEvents: ['emptied', 'loadstart'], videoRenditionsEvents: ['addrendition', 'removerendition'], }, mediaAudioTrackUnavailable: { get(stateOwners) { const { media } = stateOwners; if (!media?.audioTracks) { return AvailabilityStates.UNSUPPORTED; } // An audio selection is only possible if there are 2 or more audio tracks. if ((media.audioTracks?.length ?? 0) <= 1) { return AvailabilityStates.UNAVAILABLE; } return undefined; }, mediaEvents: ['emptied', 'loadstart'], audioTracksEvents: ['addtrack', 'removetrack'], }, };