node_modules ignore

This commit is contained in:
2025-05-08 23:43:47 +02:00
parent e19d52f172
commit 4574544c9f
65041 changed files with 10593536 additions and 0 deletions

3
server/node_modules/media-chrome/src/js/all.ts generated vendored Normal file
View File

@@ -0,0 +1,3 @@
export * from './index.js';
export * from './menu/index.js';
export * from './media-theme-element.js';

245
server/node_modules/media-chrome/src/js/constants.ts generated vendored Normal file
View File

@@ -0,0 +1,245 @@
export const MediaUIEvents = {
MEDIA_PLAY_REQUEST: 'mediaplayrequest',
MEDIA_PAUSE_REQUEST: 'mediapauserequest',
MEDIA_MUTE_REQUEST: 'mediamuterequest',
MEDIA_UNMUTE_REQUEST: 'mediaunmuterequest',
MEDIA_VOLUME_REQUEST: 'mediavolumerequest',
MEDIA_SEEK_REQUEST: 'mediaseekrequest',
MEDIA_AIRPLAY_REQUEST: 'mediaairplayrequest',
MEDIA_ENTER_FULLSCREEN_REQUEST: 'mediaenterfullscreenrequest',
MEDIA_EXIT_FULLSCREEN_REQUEST: 'mediaexitfullscreenrequest',
MEDIA_PREVIEW_REQUEST: 'mediapreviewrequest',
MEDIA_ENTER_PIP_REQUEST: 'mediaenterpiprequest',
MEDIA_EXIT_PIP_REQUEST: 'mediaexitpiprequest',
MEDIA_ENTER_CAST_REQUEST: 'mediaentercastrequest',
MEDIA_EXIT_CAST_REQUEST: 'mediaexitcastrequest',
MEDIA_SHOW_TEXT_TRACKS_REQUEST: 'mediashowtexttracksrequest',
MEDIA_HIDE_TEXT_TRACKS_REQUEST: 'mediahidetexttracksrequest',
MEDIA_SHOW_SUBTITLES_REQUEST: 'mediashowsubtitlesrequest',
MEDIA_DISABLE_SUBTITLES_REQUEST: 'mediadisablesubtitlesrequest',
MEDIA_TOGGLE_SUBTITLES_REQUEST: 'mediatogglesubtitlesrequest',
MEDIA_PLAYBACK_RATE_REQUEST: 'mediaplaybackraterequest',
MEDIA_RENDITION_REQUEST: 'mediarenditionrequest',
MEDIA_AUDIO_TRACK_REQUEST: 'mediaaudiotrackrequest',
MEDIA_SEEK_TO_LIVE_REQUEST: 'mediaseektoliverequest',
REGISTER_MEDIA_STATE_RECEIVER: 'registermediastatereceiver',
UNREGISTER_MEDIA_STATE_RECEIVER: 'unregistermediastatereceiver',
} as const;
export type MediaUIEvents = typeof MediaUIEvents;
export const MediaStateReceiverAttributes = {
MEDIA_CHROME_ATTRIBUTES: 'mediachromeattributes',
MEDIA_CONTROLLER: 'mediacontroller',
} as const;
export type MediaStateReceiverAttributes = typeof MediaStateReceiverAttributes;
export const MediaUIProps = {
MEDIA_AIRPLAY_UNAVAILABLE: 'mediaAirplayUnavailable',
MEDIA_FULLSCREEN_UNAVAILABLE: 'mediaFullscreenUnavailable',
MEDIA_PIP_UNAVAILABLE: 'mediaPipUnavailable',
MEDIA_CAST_UNAVAILABLE: 'mediaCastUnavailable',
MEDIA_RENDITION_UNAVAILABLE: 'mediaRenditionUnavailable',
MEDIA_AUDIO_TRACK_UNAVAILABLE: 'mediaAudioTrackUnavailable',
MEDIA_WIDTH: 'mediaWidth',
MEDIA_HEIGHT: 'mediaHeight',
MEDIA_PAUSED: 'mediaPaused',
MEDIA_HAS_PLAYED: 'mediaHasPlayed',
MEDIA_ENDED: 'mediaEnded',
MEDIA_MUTED: 'mediaMuted',
MEDIA_VOLUME_LEVEL: 'mediaVolumeLevel',
MEDIA_VOLUME: 'mediaVolume',
MEDIA_VOLUME_UNAVAILABLE: 'mediaVolumeUnavailable',
MEDIA_IS_PIP: 'mediaIsPip',
MEDIA_IS_CASTING: 'mediaIsCasting',
MEDIA_IS_AIRPLAYING: 'mediaIsAirplaying',
MEDIA_SUBTITLES_LIST: 'mediaSubtitlesList',
MEDIA_SUBTITLES_SHOWING: 'mediaSubtitlesShowing',
MEDIA_IS_FULLSCREEN: 'mediaIsFullscreen',
MEDIA_PLAYBACK_RATE: 'mediaPlaybackRate',
MEDIA_CURRENT_TIME: 'mediaCurrentTime',
MEDIA_DURATION: 'mediaDuration',
MEDIA_SEEKABLE: 'mediaSeekable',
MEDIA_PREVIEW_TIME: 'mediaPreviewTime',
MEDIA_PREVIEW_IMAGE: 'mediaPreviewImage',
MEDIA_PREVIEW_COORDS: 'mediaPreviewCoords',
MEDIA_PREVIEW_CHAPTER: 'mediaPreviewChapter',
MEDIA_LOADING: 'mediaLoading',
MEDIA_BUFFERED: 'mediaBuffered',
MEDIA_STREAM_TYPE: 'mediaStreamType',
MEDIA_TARGET_LIVE_WINDOW: 'mediaTargetLiveWindow',
MEDIA_TIME_IS_LIVE: 'mediaTimeIsLive',
MEDIA_RENDITION_LIST: 'mediaRenditionList',
MEDIA_RENDITION_SELECTED: 'mediaRenditionSelected',
MEDIA_AUDIO_TRACK_LIST: 'mediaAudioTrackList',
MEDIA_AUDIO_TRACK_ENABLED: 'mediaAudioTrackEnabled',
MEDIA_CHAPTERS_CUES: 'mediaChaptersCues',
} as const;
export type MediaUIProps = typeof MediaUIProps;
type Entries<T> = { [k in keyof T]: [k, T[k]] }[keyof T][];
type LowercaseValues<T extends Record<any, string>> = {
[k in keyof T]: Lowercase<T[k]>;
};
type Writeable<T> = {
-readonly [k in keyof T]: T[k];
};
type MediaUIPropsEntries = Entries<MediaUIProps>;
const MediaUIPropsEntries: MediaUIPropsEntries = Object.entries(
MediaUIProps
) as MediaUIPropsEntries;
export type MediaUIAttributes = LowercaseValues<MediaUIProps>;
export const MediaUIAttributes = MediaUIPropsEntries.reduce(
(dictObj, [key, propName]) => {
// @ts-ignore
dictObj[key] = propName.toLowerCase();
return dictObj;
},
{} as Partial<Writeable<MediaUIAttributes>>
) as MediaUIAttributes;
const AdditionalStateChangeEvents = {
USER_INACTIVE: 'userinactivechange',
BREAKPOINTS_CHANGE: 'breakpointchange',
BREAKPOINTS_COMPUTED: 'breakpointscomputed',
} as const;
export type MediaStateChangeEvents = {
[k in keyof MediaUIProps]: Lowercase<MediaUIProps[k]>;
} & typeof AdditionalStateChangeEvents;
/** @TODO In a prior migration, we dropped the 'change' from our state change event types. Although a breaking change, we should consider re-adding (CJP) */
// export type MediaStateChangeEvents = {
// [k in keyof MediaUIProps]: `${Lowercase<MediaUIProps[k]>}change`;
// } & typeof AdditionalStateChangeEvents;
export const MediaStateChangeEvents = MediaUIPropsEntries.reduce(
(dictObj, [key, propName]) => {
// @ts-ignore
dictObj[key] = propName.toLowerCase();
// dictObj[key] = `${propName.toLowerCase()}change`;
return dictObj;
},
{ ...AdditionalStateChangeEvents } as Partial<
Writeable<MediaStateChangeEvents>
>
) as MediaStateChangeEvents;
/** @TODO Make types more precise derivations, at least after updates to event type names mentioned above (CJP) */
export type StateChangeEventToAttributeMap = {
[k in MediaStateChangeEvents[keyof MediaStateChangeEvents &
keyof MediaUIAttributes]]: MediaUIAttributes[keyof MediaUIAttributes];
} & { userinactivechange: 'userinactive' };
// Maps from state change event type -> attribute name
export const StateChangeEventToAttributeMap = Object.entries(
MediaStateChangeEvents
).reduce(
(mapObj, [key, eventType]) => {
const attrName = MediaUIAttributes[key];
if (attrName) {
mapObj[eventType] = attrName;
}
return mapObj;
},
{ userinactivechange: 'userinactive' } as Partial<
Writeable<StateChangeEventToAttributeMap>
>
) as StateChangeEventToAttributeMap;
/** @TODO Make types more precise derivations, at least after updates to event type names mentioned above (CJP) */
export type AttributeToStateChangeEventMap = {
[k in MediaUIAttributes[keyof MediaUIAttributes &
keyof MediaStateChangeEvents]]: MediaStateChangeEvents[keyof MediaStateChangeEvents];
} & { userinactive: 'userinactivechange' };
// Maps from attribute name -> state change event type
export const AttributeToStateChangeEventMap = Object.entries(
MediaUIAttributes
).reduce(
(mapObj, [key, attrName]) => {
const evtType = MediaStateChangeEvents[key];
if (evtType) {
mapObj[attrName] = evtType;
}
return mapObj;
},
{ userinactive: 'userinactivechange' } as Partial<
Writeable<AttributeToStateChangeEventMap>
>
) as AttributeToStateChangeEventMap;
export const TextTrackKinds = {
SUBTITLES: 'subtitles',
CAPTIONS: 'captions',
DESCRIPTIONS: 'descriptions',
CHAPTERS: 'chapters',
METADATA: 'metadata',
} as const;
export type TextTrackKinds = typeof TextTrackKinds[keyof typeof TextTrackKinds];
export const TextTrackModes = {
DISABLED: 'disabled',
HIDDEN: 'hidden',
SHOWING: 'showing',
} as const;
export type TextTrackModes = typeof TextTrackModes;
export const ReadyStates = {
HAVE_NOTHING: 0,
HAVE_METADATA: 1,
HAVE_CURRENT_DATA: 2,
HAVE_FUTURE_DATA: 3,
HAVE_ENOUGH_DATA: 4,
} as const;
export type ReadyStates = typeof ReadyStates;
export const PointerTypes = {
MOUSE: 'mouse',
PEN: 'pen',
TOUCH: 'touch',
} as const;
export type PointerTypes = typeof PointerTypes;
export const AvailabilityStates = {
UNAVAILABLE: 'unavailable',
UNSUPPORTED: 'unsupported',
} as const;
export type AvailabilityStates =
typeof AvailabilityStates[keyof typeof AvailabilityStates];
export const StreamTypes = {
LIVE: 'live',
ON_DEMAND: 'on-demand',
UNKNOWN: 'unknown',
} as const;
export type StreamTypes = typeof StreamTypes[keyof typeof StreamTypes];
export const VolumeLevels = {
HIGH: 'high',
MEDIUM: 'medium',
LOW: 'low',
OFF: 'off',
} as const;
export type VolumeLevels = typeof VolumeLevels;
export const WebkitPresentationModes = {
INLINE: 'inline',
FULLSCREEN: 'fullscreen',
PICTURE_IN_PICTURE: 'picture-in-picture',
} as const;
export type WebkitPresentationModes = typeof WebkitPresentationModes;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,514 @@
import { globalThis, document } from '../../utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from '../../constants.js';
const template: HTMLTemplateElement = document.createElement('template');
const HANDLE_W = 8;
const Z = {
100: 100,
200: 200,
300: 300,
};
function lockBetweenZeroAndOne(num: number): number {
return Math.max(0, Math.min(1, num));
}
template.innerHTML = `
<style>
#selectorContainer {
background-color: transparent;
height: 44px;
width: 100%;
display: flex;
position: relative;
}
#timeline {
width: 100%;
height: 10px;
background: #ccc;
position: absolute;
top: 16px;
z-index: ${Z['100']};
}
#startHandle, #endHandle {
cursor: pointer;
height: 80%;
width: ${HANDLE_W}px;
border-radius: 4px;
background-color: royalblue;
}
#playhead {
height: 100%;
width: 3px;
background-color: #aaa;
position: absolute;
display: none;
z-index: ${Z['300']};
}
#selection {
display: flex;
z-index: ${Z['200']};
width: 25%;
height: 100%;
align-items: center;
}
#leftTrim {
width: 25%;
}
#spacer {
flex: 1;
background-color: cornflowerblue;
height: 40%;
}
#thumbnailContainer {
display: none;
position: absolute;
top: 0;
}
media-preview-thumbnail {
position: absolute;
bottom: 10px;
border: 2px solid #fff;
border-radius: 2px;
background-color: #000;
width: 160px;
height: 90px;
/* Negative offset of half to center on the handle */
margin-left: -80px;
}
/* Can't get this working. Trying a downward triangle. */
/* media-preview-thumbnail::after {
content: "";
display: block;
width: 300px;
height: 300px;
margin: 100px;
background-color: #ff0;
} */
:host(:hover) #thumbnailContainer.enabled {
display: block;
animation: fadeIn ease 0.5s;
}
@keyframes fadeIn {
0% {
/* transform-origin: bottom center; */
/* transform: scale(0.7); */
margin-top: 10px;
opacity: 0;
}
100% {
/* transform-origin: bottom center; */
/* transform: scale(1); */
margin-top: 0;
opacity: 1;
}
}
</style>
<div id="thumbnailContainer">
<media-preview-thumbnail></media-preview-thumbnail>
</div>
<div id="selectorContainer">
<div id="timeline"></div>
<div id="playhead"></div>
<div id="leftTrim"></div>
<div id="selection">
<div id="startHandle"></div>
<div id="spacer"></div>
<div id="endHandle"></div>
</div>
</div>
`;
/**
*
*/
class MediaClipSelector extends globalThis.HTMLElement {
static get observedAttributes() {
return [
'thumbnails',
MediaUIAttributes.MEDIA_DURATION,
MediaUIAttributes.MEDIA_CURRENT_TIME,
];
}
draggingEl: HTMLElement | null;
wrapper: HTMLElement;
selection: HTMLElement;
playhead: HTMLElement;
leftTrim: HTMLElement;
spacerFirst: HTMLElement;
startHandle: HTMLElement;
spacerMiddle: HTMLElement;
endHandle: HTMLElement;
spacerLast: HTMLElement;
initialX: number;
thumbnailPreview: HTMLElement;
_clickHandler: () => void;
_dragStart: () => void;
_dragEnd: () => void;
_drag: () => void;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
// @ts-ignore
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
this.draggingEl = null;
this.wrapper = this.shadowRoot.querySelector('#selectorContainer');
this.selection = this.shadowRoot.querySelector('#selection');
this.playhead = this.shadowRoot.querySelector('#playhead');
this.leftTrim = this.shadowRoot.querySelector('#leftTrim');
this.spacerFirst = this.shadowRoot.querySelector('#spacerFirst');
this.startHandle = this.shadowRoot.querySelector('#startHandle');
this.spacerMiddle = this.shadowRoot.querySelector('#spacerMiddle');
this.endHandle = this.shadowRoot.querySelector('#endHandle');
this.spacerLast = this.shadowRoot.querySelector('#spacerLast');
this._clickHandler = this.handleClick.bind(this);
this._dragStart = this.dragStart.bind(this);
this._dragEnd = this.dragEnd.bind(this);
this._drag = this.drag.bind(this);
this.wrapper.addEventListener('click', this._clickHandler, false);
this.wrapper.addEventListener('touchstart', this._dragStart, false);
globalThis.window?.addEventListener('touchend', this._dragEnd, false);
this.wrapper.addEventListener('touchmove', this._drag, false);
this.wrapper.addEventListener('mousedown', this._dragStart, false);
globalThis.window?.addEventListener('mouseup', this._dragEnd, false);
globalThis.window?.addEventListener('mousemove', this._drag, false);
this.enableThumbnails();
}
get mediaDuration(): number {
return +this.getAttribute(MediaUIAttributes.MEDIA_DURATION);
}
get mediaCurrentTime(): number {
return +this.getAttribute(MediaUIAttributes.MEDIA_CURRENT_TIME);
}
/*
* pass in a mouse event (evt.clientX)
* calculates the percentage progress based on the bounding rectang
* converts the percentage progress into a duration in seconds
*/
getPlayheadBasedOnMouseEvent(evt: MouseEvent): number {
const duration = this.mediaDuration;
if (!duration) return;
const mousePercent = lockBetweenZeroAndOne(this.getMousePercent(evt));
return mousePercent * duration;
}
getXPositionFromMouse(evt: any): number {
let clientX;
if (['touchstart', 'touchmove'].includes(evt.type)) {
clientX = evt.touches[0].clientX;
}
return clientX || evt.clientX;
}
getMousePercent(evt: MouseEvent): number {
const rangeRect = this.wrapper.getBoundingClientRect();
const mousePercent =
(this.getXPositionFromMouse(evt) - rangeRect.left) / rangeRect.width;
return lockBetweenZeroAndOne(mousePercent);
}
dragStart(evt: MouseEvent): void {
if (evt.target === this.startHandle) {
this.draggingEl = this.startHandle;
}
if (evt.target === this.endHandle) {
this.draggingEl = this.endHandle;
}
this.initialX = this.getXPositionFromMouse(evt);
}
dragEnd(): void {
this.initialX = null;
this.draggingEl = null;
}
setSelectionWidth(selectionPercent: number, fullTimelineWidth: number): void {
let percent = selectionPercent;
const minWidthPx = HANDLE_W * 3;
const minWidthPercent = lockBetweenZeroAndOne(
minWidthPx / fullTimelineWidth
);
if (percent < minWidthPercent) {
percent = minWidthPercent;
}
/*
* The selection can never be smaller than the width
* of 3 handles
if (percent === 0) {
percent = minWidthPercent;
}
*/
this.selection.style.width = `${percent * 100}%`;
}
drag(evt: MouseEvent): void {
if (!this.draggingEl) {
return;
}
evt.preventDefault();
const rangeRect = this.wrapper.getBoundingClientRect();
const fullTimelineWidth = rangeRect.width;
const endXPosition = this.getXPositionFromMouse(evt);
const xDelta = endXPosition - this.initialX;
const percent = this.getMousePercent(evt);
const selectionW = this.selection.getBoundingClientRect().width;
/*
* When dragging the start handle, change the leftTrim width
* and the selection width
*/
if (this.draggingEl === this.startHandle) {
this.initialX = this.getXPositionFromMouse(evt);
this.leftTrim.style.width = `${percent * 100}%`;
const selectionPercent = lockBetweenZeroAndOne(
(selectionW - xDelta) / fullTimelineWidth
);
this.setSelectionWidth(selectionPercent, fullTimelineWidth);
}
/*
* When dragging the end handle all we need to do is change
* the selection width
*/
if (this.draggingEl === this.endHandle) {
this.initialX = this.getXPositionFromMouse(evt);
const selectionPercent = lockBetweenZeroAndOne(
(selectionW + xDelta) / fullTimelineWidth
);
this.setSelectionWidth(selectionPercent, fullTimelineWidth);
}
this.dispatchUpdate();
}
dispatchUpdate(): void {
const updateEvent = new CustomEvent('update', {
detail: this.getCurrentClipBounds(),
});
this.dispatchEvent(updateEvent);
}
getCurrentClipBounds(): { startTime: number; endTime: number } {
const rangeRect = this.wrapper.getBoundingClientRect();
const leftTrimRect = this.leftTrim.getBoundingClientRect();
const selectionRect = this.selection.getBoundingClientRect();
const percentStart = lockBetweenZeroAndOne(
leftTrimRect.width / rangeRect.width
);
const percentEnd = lockBetweenZeroAndOne(
(leftTrimRect.width + selectionRect.width) / rangeRect.width
);
/*
* Currently we round to the nearest integer? Might want to change later to round to 1 or 2 decimails?
*/
return {
startTime: Math.round(percentStart * this.mediaDuration),
endTime: Math.round(percentEnd * this.mediaDuration),
};
}
isTimestampInBounds(timestamp: number): boolean {
const { startTime, endTime } = this.getCurrentClipBounds();
return startTime <= timestamp && endTime >= timestamp;
}
handleClick(evt: MouseEvent): void {
const mousePercent = this.getMousePercent(evt);
const timestampForClick = mousePercent * this.mediaDuration;
/*
* Clicking outside the selection (out of bounds), does not change the
* currentTime of the underlying media, only clicking in bounds does that
*/
if (this.isTimestampInBounds(timestampForClick)) {
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail: timestampForClick,
})
);
}
}
mediaCurrentTimeSet(): void {
const percentComplete = lockBetweenZeroAndOne(
this.mediaCurrentTime / this.mediaDuration
);
// const fullW = this.wrapper.getBoundingClientRect().width;
// const progressW = percentComplete * fullW;
this.playhead.style.left = `${percentComplete * 100}%`;
this.playhead.style.display = 'block';
/*
* if paused, we don't need to do anything else, but if it is playing
* we want to loop within the selection range
*/
// @ts-ignore
if (!this.mediaPaused) {
const { startTime, endTime } = this.getCurrentClipBounds();
if (
this.mediaCurrentTime < startTime ||
this.mediaCurrentTime > endTime
) {
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail: startTime,
})
);
}
}
}
mediaUnsetCallback(media: HTMLVideoElement): void {
// @ts-ignore
super.mediaUnsetCallback(media);
this.wrapper.removeEventListener('touchstart', this._dragStart);
this.wrapper.removeEventListener('touchend', this._dragEnd);
this.wrapper.removeEventListener('touchmove', this._drag);
this.wrapper.removeEventListener('mousedown', this._dragStart);
globalThis.window?.removeEventListener('mouseup', this._dragEnd);
globalThis.window?.removeEventListener('mousemove', this._drag);
}
/*
* This was copied over from media-time-range, we should have a way of making
* this code shared between the two components
*/
enableThumbnails(): void {
/** @type {HTMLElement} */
this.thumbnailPreview = this.shadowRoot.querySelector(
'media-preview-thumbnail'
);
/** @type {HTMLElement} */
const thumbnailContainer = this.shadowRoot.querySelector(
'#thumbnailContainer'
);
thumbnailContainer.classList.add('enabled');
let mouseMoveHandler;
const trackMouse = () => {
mouseMoveHandler = (evt) => {
const duration = this.mediaDuration;
// If no duration we can't calculate which time to show
if (!duration) return;
// Get mouse position percent
const rangeRect = this.wrapper.getBoundingClientRect();
const mousePercent = this.getMousePercent(evt);
// Get thumbnail center position
const leftPadding = rangeRect.left - this.getBoundingClientRect().left;
const thumbnailLeft = leftPadding + mousePercent * rangeRect.width;
this.thumbnailPreview.style.left = `${thumbnailLeft}px`;
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_PREVIEW_REQUEST, {
composed: true,
bubbles: true,
detail: mousePercent * duration,
})
);
};
globalThis.window?.addEventListener('mousemove', mouseMoveHandler, false);
};
const stopTrackingMouse = () => {
globalThis.window?.removeEventListener('mousemove', mouseMoveHandler);
};
// Trigger when the mouse moves over the range
let rangeEntered = false;
const rangeMouseMoveHander = () => {
if (!rangeEntered && this.mediaDuration) {
rangeEntered = true;
this.thumbnailPreview.style.display = 'block';
trackMouse();
const offRangeHandler = (evt) => {
if (evt.target != this && !this.contains(evt.target)) {
this.thumbnailPreview.style.display = 'none';
globalThis.window?.removeEventListener(
'mousemove',
offRangeHandler
);
rangeEntered = false;
stopTrackingMouse();
}
};
globalThis.window?.addEventListener(
'mousemove',
offRangeHandler,
false
);
}
if (!this.mediaDuration) {
this.thumbnailPreview.style.display = 'none';
}
};
this.addEventListener('mousemove', rangeMouseMoveHander, false);
}
disableThumbnails(): void {
const thumbnailContainer = this.shadowRoot.querySelector(
'#thumbnailContainer'
);
thumbnailContainer.classList.remove('enabled');
}
}
if (!globalThis.customElements.get('media-clip-selector')) {
globalThis.customElements.define('media-clip-selector', MediaClipSelector);
}
export default MediaClipSelector;

63
server/node_modules/media-chrome/src/js/index.ts generated vendored Normal file
View File

@@ -0,0 +1,63 @@
export * as constants from './constants.js';
export { default as labels } from './labels/labels.js';
export * as timeUtils from './utils/time.js';
// Import media-controller first to ensure it's available for other components
// when calling `associateElement(this)` in connectedCallback.
import MediaController from './media-controller.js';
import MediaAirplayButton from './media-airplay-button.js';
import MediaCaptionsButton from './media-captions-button.js';
import MediaCastButton from './media-cast-button.js';
import MediaChromeButton from './media-chrome-button.js';
import MediaChromeDialog from './media-chrome-dialog.js';
import MediaChromeRange from './media-chrome-range.js';
import MediaControlBar from './media-control-bar.js';
import MediaDurationDisplay from './media-duration-display.js';
import MediaFullscreenButton from './media-fullscreen-button.js';
import MediaGestureReceiver from './media-gesture-receiver.js';
import MediaLiveButton from './media-live-button.js';
import MediaLoadingIndicator from './media-loading-indicator.js';
import MediaMuteButton from './media-mute-button.js';
import MediaPipButton from './media-pip-button.js';
import MediaPlaybackRateButton from './media-playback-rate-button.js';
import MediaPlayButton from './media-play-button.js';
import MediaPosterImage from './media-poster-image.js';
import MediaPreviewChapterDisplay from './media-preview-chapter-display.js';
import MediaPreviewThumbnail from './media-preview-thumbnail.js';
import MediaPreviewTimeDisplay from './media-preview-time-display.js';
import MediaSeekBackwardButton from './media-seek-backward-button.js';
import MediaSeekForwardButton from './media-seek-forward-button.js';
import MediaTimeDisplay from './media-time-display.js';
import MediaTimeRange from './media-time-range.js';
import MediaTooltip from './media-tooltip.js';
import MediaVolumeRange from './media-volume-range.js';
export {
MediaAirplayButton,
MediaCaptionsButton,
MediaCastButton,
MediaChromeButton,
MediaChromeDialog,
MediaChromeRange,
MediaControlBar,
MediaController,
MediaDurationDisplay,
MediaFullscreenButton,
MediaGestureReceiver,
MediaLiveButton,
MediaLoadingIndicator,
MediaMuteButton,
MediaPipButton,
MediaPlaybackRateButton,
MediaPlayButton,
MediaPosterImage,
MediaPreviewChapterDisplay,
MediaPreviewThumbnail,
MediaPreviewTimeDisplay,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaTooltip,
MediaVolumeRange,
};

View File

@@ -0,0 +1,66 @@
export type LabelOptions = { seekOffset?: number; playbackRate?: number };
export const tooltipLabels = {
ENTER_AIRPLAY: 'Start airplay',
EXIT_AIRPLAY: 'Stop airplay',
AUDIO_TRACK_MENU: 'Audio',
CAPTIONS: 'Captions',
ENABLE_CAPTIONS: 'Enable captions',
DISABLE_CAPTIONS: 'Disable captions',
START_CAST: 'Start casting',
STOP_CAST: 'Stop casting',
ENTER_FULLSCREEN: 'Enter fullscreen mode',
EXIT_FULLSCREEN: 'Exit fullscreen mode',
MUTE: 'Mute',
UNMUTE: 'Unmute',
ENTER_PIP: 'Enter picture in picture mode',
EXIT_PIP: 'Enter picture in picture mode',
PLAY: 'Play',
PAUSE: 'Pause',
PLAYBACK_RATE: 'Playback rate',
RENDITIONS: 'Quality',
SEEK_BACKWARD: 'Seek backward',
SEEK_FORWARD: 'Seek forward',
SETTINGS: 'Settings',
};
export const nouns: Record<string, (options?: LabelOptions) => string> = {
AUDIO_PLAYER: () => 'audio player',
VIDEO_PLAYER: () => 'video player',
VOLUME: () => 'volume',
SEEK: () => 'seek',
CLOSED_CAPTIONS: () => 'closed captions',
PLAYBACK_RATE: ({ playbackRate = 1 } = {}) =>
`current playback rate ${playbackRate}`,
PLAYBACK_TIME: () => `playback time`,
MEDIA_LOADING: () => `media loading`,
SETTINGS: () => `settings`,
AUDIO_TRACKS: () => `audio tracks`,
QUALITY: () => `quality`,
};
export const verbs: Record<string, (options?: LabelOptions) => string> = {
PLAY: () => 'play',
PAUSE: () => 'pause',
MUTE: () => 'mute',
UNMUTE: () => 'unmute',
ENTER_AIRPLAY: () => 'start airplay',
EXIT_AIRPLAY: () => 'stop airplay',
ENTER_CAST: () => 'start casting',
EXIT_CAST: () => 'stop casting',
ENTER_FULLSCREEN: () => 'enter fullscreen mode',
EXIT_FULLSCREEN: () => 'exit fullscreen mode',
ENTER_PIP: () => 'enter picture in picture mode',
EXIT_PIP: () => 'exit picture in picture mode',
SEEK_FORWARD_N_SECS: ({ seekOffset = 30 } = {}) =>
`seek forward ${seekOffset} seconds`,
SEEK_BACK_N_SECS: ({ seekOffset = 30 } = {}) =>
`seek back ${seekOffset} seconds`,
SEEK_LIVE: () => 'seek to live',
PLAYING_LIVE: () => 'playing live',
};
export default {
...nouns,
...verbs,
};

View File

@@ -0,0 +1,139 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import {
getStringAttr,
setStringAttr,
getBooleanAttr,
setBooleanAttr,
} from './utils/element-utils.js';
const airplayIcon = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M22.13 3H3.87a.87.87 0 0 0-.87.87v13.26a.87.87 0 0 0 .87.87h3.4L9 16H5V5h16v11h-4l1.72 2h3.4a.87.87 0 0 0 .87-.87V3.87a.87.87 0 0 0-.86-.87Zm-8.75 11.44a.5.5 0 0 0-.76 0l-4.91 5.73a.5.5 0 0 0 .38.83h9.82a.501.501 0 0 0 .38-.83l-4.91-5.73Z"/>
</svg>
`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([${
MediaUIAttributes.MEDIA_IS_AIRPLAYING
}]) slot[name=icon] slot:not([name=exit]) {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([${
MediaUIAttributes.MEDIA_IS_AIRPLAYING
}])) slot[name=icon] slot:not([name=enter]) {
display: none !important;
}
:host([${MediaUIAttributes.MEDIA_IS_AIRPLAYING}]) slot[name=tooltip-enter],
:host(:not([${
MediaUIAttributes.MEDIA_IS_AIRPLAYING
}])) slot[name=tooltip-exit] {
display: none;
}
</style>
<slot name="icon">
<slot name="enter">${airplayIcon}</slot>
<slot name="exit">${airplayIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-enter">${tooltipLabels.ENTER_AIRPLAY}</slot>
<slot name="tooltip-exit">${tooltipLabels.EXIT_AIRPLAY}</slot>
`;
const updateAriaLabel = (el: MediaAirplayButton): void => {
const label = el.mediaIsAirplaying
? verbs.EXIT_AIRPLAY()
: verbs.ENTER_AIRPLAY();
el.setAttribute('aria-label', label);
};
/**
* @slot enter - An element shown when the media is not in AirPlay mode and pressing the button will open the AirPlay menu.
* @slot exit - An element shown when the media is in AirPlay mode and pressing the button will open the AirPlay menu.
* @slot icon - The element shown for the AirPlay buttons display.
*
* @attr {(unavailable|unsupported)} mediaairplayunavailable - (read-only) Set if AirPlay is unavailable.
* @attr {boolean} mediaisairplaying - (read-only) Present if the media is airplaying.
*
* @cssproperty [--media-airplay-button-display = inline-flex] - `display` property of button.
*
* @event {CustomEvent} mediaairplayrequest
*/
class MediaAirplayButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_IS_AIRPLAYING,
MediaUIAttributes.MEDIA_AIRPLAY_UNAVAILABLE,
];
}
constructor(options: { slotTemplate?: HTMLTemplateElement } = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
super.connectedCallback();
updateAriaLabel(this);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_IS_AIRPLAYING) {
updateAriaLabel(this);
}
}
/**
* Are we currently airplaying
*/
get mediaIsAirplaying(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_IS_AIRPLAYING);
}
set mediaIsAirplaying(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_IS_AIRPLAYING, value);
}
/**
* Airplay unavailability state
*/
get mediaAirplayUnavailable(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_AIRPLAY_UNAVAILABLE);
}
set mediaAirplayUnavailable(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_AIRPLAY_UNAVAILABLE, value);
}
handleClick(): void {
const evt = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_AIRPLAY_REQUEST,
{
composed: true,
bubbles: true,
}
);
this.dispatchEvent(evt);
}
}
if (!globalThis.customElements.get('media-airplay-button')) {
globalThis.customElements.define('media-airplay-button', MediaAirplayButton);
}
export default MediaAirplayButton;

View File

@@ -0,0 +1,183 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIAttributes, MediaUIEvents } from './constants.js';
import { nouns, tooltipLabels } from './labels/labels.js';
import {
areSubsOn,
parseTextTracksStr,
stringifyTextTrackList,
} from './utils/captions.js';
import { TextTrackLike } from './utils/TextTrackLike.js';
const ccIconOn = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M22.83 5.68a2.58 2.58 0 0 0-2.3-2.5c-3.62-.24-11.44-.24-15.06 0a2.58 2.58 0 0 0-2.3 2.5c-.23 4.21-.23 8.43 0 12.64a2.58 2.58 0 0 0 2.3 2.5c3.62.24 11.44.24 15.06 0a2.58 2.58 0 0 0 2.3-2.5c.23-4.21.23-8.43 0-12.64Zm-11.39 9.45a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.92 3.92 0 0 1 .92-2.77 3.18 3.18 0 0 1 2.43-1 2.94 2.94 0 0 1 2.13.78c.364.359.62.813.74 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.17 1.61 1.61 0 0 0-1.29.58 2.79 2.79 0 0 0-.5 1.89 3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.48 1.48 0 0 0 1-.37 2.1 2.1 0 0 0 .59-1.14l1.4.44a3.23 3.23 0 0 1-1.07 1.69Zm7.22 0a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.88 3.88 0 0 1 .93-2.77 3.14 3.14 0 0 1 2.42-1 3 3 0 0 1 2.16.82 2.8 2.8 0 0 1 .73 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.21 1.61 1.61 0 0 0-1.29.58A2.79 2.79 0 0 0 15 12a3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.44 1.44 0 0 0 1-.37 2.1 2.1 0 0 0 .6-1.15l1.4.44a3.17 3.17 0 0 1-1.1 1.7Z"/>
</svg>`;
const ccIconOff = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M17.73 14.09a1.4 1.4 0 0 1-1 .37 1.579 1.579 0 0 1-1.27-.58A3 3 0 0 1 15 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34A2.89 2.89 0 0 0 19 9.07a3 3 0 0 0-2.14-.78 3.14 3.14 0 0 0-2.42 1 3.91 3.91 0 0 0-.93 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.17 3.17 0 0 0 1.07-1.74l-1.4-.45c-.083.43-.3.822-.62 1.12Zm-7.22 0a1.43 1.43 0 0 1-1 .37 1.58 1.58 0 0 1-1.27-.58A3 3 0 0 1 7.76 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34a2.81 2.81 0 0 0-.74-1.32 2.94 2.94 0 0 0-2.13-.78 3.18 3.18 0 0 0-2.43 1 4 4 0 0 0-.92 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.23 3.23 0 0 0 1.07-1.74l-1.4-.45a2.06 2.06 0 0 1-.6 1.07Zm12.32-8.41a2.59 2.59 0 0 0-2.3-2.51C18.72 3.05 15.86 3 13 3c-2.86 0-5.72.05-7.53.17a2.59 2.59 0 0 0-2.3 2.51c-.23 4.207-.23 8.423 0 12.63a2.57 2.57 0 0 0 2.3 2.5c1.81.13 4.67.19 7.53.19 2.86 0 5.72-.06 7.53-.19a2.57 2.57 0 0 0 2.3-2.5c.23-4.207.23-8.423 0-12.63Zm-1.49 12.53a1.11 1.11 0 0 1-.91 1.11c-1.67.11-4.45.18-7.43.18-2.98 0-5.76-.07-7.43-.18a1.11 1.11 0 0 1-.91-1.11c-.21-4.14-.21-8.29 0-12.43a1.11 1.11 0 0 1 .91-1.11C7.24 4.56 10 4.49 13 4.49s5.76.07 7.43.18a1.11 1.11 0 0 1 .91 1.11c.21 4.14.21 8.29 0 12.43Z"/>
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([aria-checked="true"]) slot[name=off] {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([aria-checked="true"])) slot[name=on] {
display: none !important;
}
:host([aria-checked="true"]) slot[name=tooltip-enable],
:host(:not([aria-checked="true"])) slot[name=tooltip-disable] {
display: none;
}
</style>
<slot name="icon">
<slot name="on">${ccIconOn}</slot>
<slot name="off">${ccIconOff}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-enable">${tooltipLabels.ENABLE_CAPTIONS}</slot>
<slot name="tooltip-disable">${tooltipLabels.DISABLE_CAPTIONS}</slot>
`;
const updateAriaChecked = (el: HTMLElement) => {
el.setAttribute('aria-checked', areSubsOn(el).toString());
};
/**
* @slot on - An element that will be shown while closed captions or subtitles are on.
* @slot off - An element that will be shown while closed captions or subtitles are off.
* @slot icon - An element for representing on and off states in a single icon
*
* @attr {string} mediasubtitleslist - (read-only) A list of all subtitles and captions.
* @attr {string} mediasubtitlesshowing - (read-only) A list of the showing subtitles and captions.
*
* @cssproperty [--media-captions-button-display = inline-flex] - `display` property of button.
*/
class MediaCaptionsButton extends MediaChromeButton {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_SUBTITLES_LIST,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING,
];
}
private _captionsReady: boolean;
constructor(options: any = {}) {
super({ slotTemplate, tooltipContent, ...options });
// Internal variable to keep track of when we have some or no captions (or subtitles, if using subtitles fallback)
// Used for `default-showing` behavior.
this._captionsReady = false;
}
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'switch');
this.setAttribute('aria-label', nouns.CLOSED_CAPTIONS());
updateAriaChecked(this);
}
attributeChangedCallback(
attrName: string,
oldValue: string,
newValue: string
) {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_SUBTITLES_SHOWING) {
updateAriaChecked(this);
}
}
/**
* An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesList(): TextTrackLike[] {
return getSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST);
}
set mediaSubtitlesList(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST, list);
}
/**
* An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesShowing(): TextTrackLike[] {
return getSubtitlesListAttr(
this,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING
);
}
set mediaSubtitlesShowing(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_SHOWING, list);
}
handleClick() {
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST, {
composed: true,
bubbles: true,
})
);
}
}
/**
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName - The attribute name to get
* @returns An array of TextTrack-like objects.
*/
const getSubtitlesListAttr = (
el: HTMLElement,
attrName: string
): TextTrackLike[] => {
const attrVal = el.getAttribute(attrName);
return attrVal ? parseTextTracksStr(attrVal) : [];
};
/**
*
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName - The attribute name to set
* @param list - An array of TextTrack-like objects
*/
const setSubtitlesListAttr = (
el: HTMLElement,
attrName: string,
list: TextTrackLike[]
) => {
// null, undefined, and empty arrays are treated as "no value" here
if (!list?.length) {
el.removeAttribute(attrName);
return;
}
// don't set if the new value is the same as existing
const newValStr = stringifyTextTrackList(list);
const oldVal = el.getAttribute(attrName);
if (oldVal === newValStr) return;
el.setAttribute(attrName, newValStr);
};
if (!globalThis.customElements.get('media-captions-button')) {
globalThis.customElements.define(
'media-captions-button',
MediaCaptionsButton
);
}
export { MediaCaptionsButton };
export default MediaCaptionsButton;

View File

@@ -0,0 +1,132 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import {
getBooleanAttr,
setBooleanAttr,
getStringAttr,
setStringAttr,
} from './utils/element-utils.js';
const enterIcon = `<svg aria-hidden="true" viewBox="0 0 24 24"><g><path class="cast_caf_icon_arch0" d="M1,18 L1,21 L4,21 C4,19.3 2.66,18 1,18 L1,18 Z"/><path class="cast_caf_icon_arch1" d="M1,14 L1,16 C3.76,16 6,18.2 6,21 L8,21 C8,17.13 4.87,14 1,14 L1,14 Z"/><path class="cast_caf_icon_arch2" d="M1,10 L1,12 C5.97,12 10,16.0 10,21 L12,21 C12,14.92 7.07,10 1,10 L1,10 Z"/><path class="cast_caf_icon_box" d="M21,3 L3,3 C1.9,3 1,3.9 1,5 L1,8 L3,8 L3,5 L21,5 L21,19 L14,19 L14,21 L21,21 C22.1,21 23,20.1 23,19 L23,5 C23,3.9 22.1,3 21,3 L21,3 Z"/></g></svg>`;
const exitIcon = `<svg aria-hidden="true" viewBox="0 0 24 24"><g><path class="cast_caf_icon_arch0" d="M1,18 L1,21 L4,21 C4,19.3 2.66,18 1,18 L1,18 Z"/><path class="cast_caf_icon_arch1" d="M1,14 L1,16 C3.76,16 6,18.2 6,21 L8,21 C8,17.13 4.87,14 1,14 L1,14 Z"/><path class="cast_caf_icon_arch2" d="M1,10 L1,12 C5.97,12 10,16.0 10,21 L12,21 C12,14.92 7.07,10 1,10 L1,10 Z"/><path class="cast_caf_icon_box" d="M21,3 L3,3 C1.9,3 1,3.9 1,5 L1,8 L3,8 L3,5 L21,5 L21,19 L14,19 L14,21 L21,21 C22.1,21 23,20.1 23,19 L23,5 C23,3.9 22.1,3 21,3 L21,3 Z"/><path class="cast_caf_icon_boxfill" d="M5,7 L5,8.63 C8,8.6 13.37,14 13.37,17 L19,17 L19,7 Z"/></g></svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([${
MediaUIAttributes.MEDIA_IS_CASTING
}]) slot[name=icon] slot:not([name=exit]) {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([${
MediaUIAttributes.MEDIA_IS_CASTING
}])) slot[name=icon] slot:not([name=enter]) {
display: none !important;
}
:host([${MediaUIAttributes.MEDIA_IS_CASTING}]) slot[name=tooltip-enter],
:host(:not([${
MediaUIAttributes.MEDIA_IS_CASTING
}])) slot[name=tooltip-exit] {
display: none;
}
</style>
<slot name="icon">
<slot name="enter">${enterIcon}</slot>
<slot name="exit">${exitIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-enter">${tooltipLabels.START_CAST}</slot>
<slot name="tooltip-exit">${tooltipLabels.STOP_CAST}</slot>
`;
const updateAriaLabel = (el: MediaCastButton) => {
const label = el.mediaIsCasting ? verbs.EXIT_CAST() : verbs.ENTER_CAST();
el.setAttribute('aria-label', label);
};
/**
* @slot enter - An element shown when the media is not in casting mode and pressing the button will open the Cast menu.
* @slot exit - An element shown when the media is in casting mode and pressing the button will open the Cast menu.
* @slot icon - An element for representing enter and exit states in a single icon
*
* @attr {(unavailable|unsupported)} mediacastunavailable - (read-only) Set if casting is unavailable.
* @attr {boolean} mediaiscasting - (read-only) Present if the media is casting.
*
* @cssproperty [--media-cast-button-display = inline-flex] - `display` property of button.
*/
class MediaCastButton extends MediaChromeButton {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_IS_CASTING,
MediaUIAttributes.MEDIA_CAST_UNAVAILABLE,
];
}
constructor(options = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
super.connectedCallback();
updateAriaLabel(this);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
) {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_IS_CASTING) {
updateAriaLabel(this);
}
}
/**
* @type {boolean} Are we currently casting
*/
get mediaIsCasting(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_IS_CASTING);
}
set mediaIsCasting(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_IS_CASTING, value);
}
/**
* @type {string | undefined} Cast unavailability state
*/
get mediaCastUnavailable(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_CAST_UNAVAILABLE);
}
set mediaCastUnavailable(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_CAST_UNAVAILABLE, value);
}
handleClick() {
const eventName = this.mediaIsCasting
? MediaUIEvents.MEDIA_EXIT_CAST_REQUEST
: MediaUIEvents.MEDIA_ENTER_CAST_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
if (!globalThis.customElements.get('media-cast-button')) {
globalThis.customElements.define('media-cast-button', MediaCastButton);
}
export default MediaCastButton;

View File

@@ -0,0 +1,346 @@
import { MediaStateReceiverAttributes } from './constants.js';
import MediaTooltip, { TooltipPlacement } from './media-tooltip.js';
import {
getOrInsertCSSRule,
getStringAttr,
setStringAttr,
} from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
const Attributes = {
TOOLTIP_PLACEMENT: 'tooltipplacement',
};
const template = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
position: relative;
font: var(--media-font,
var(--media-font-weight, bold)
var(--media-font-size, 14px) /
var(--media-text-content-height, var(--media-control-height, 24px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7)));
padding: var(--media-button-padding, var(--media-control-padding, 10px));
justify-content: var(--media-button-justify-content, center);
display: inline-flex;
align-items: center;
vertical-align: middle;
box-sizing: border-box;
transition: background .15s linear;
pointer-events: auto;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
${
/*
Only show outline when keyboard focusing.
https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo
*/ ''
}
:host(:focus-visible) {
box-shadow: inset 0 0 0 2px rgb(27 127 204 / .9);
outline: 0;
}
${
/*
* hide default focus ring, particularly when using mouse
*/ ''
}
:host(:where(:focus)) {
box-shadow: none;
outline: 0;
}
:host(:hover) {
background: var(--media-control-hover-background, rgba(50 50 70 / .7));
}
svg, img, ::slotted(svg), ::slotted(img) {
width: var(--media-button-icon-width);
height: var(--media-button-icon-height, var(--media-control-height, 24px));
transform: var(--media-button-icon-transform);
transition: var(--media-button-icon-transition);
fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)));
vertical-align: middle;
max-width: 100%;
max-height: 100%;
min-width: 100%;
}
media-tooltip {
${/** Make sure unpositioned tooltip doesn't cause page overflow (scroll). */ ''}
max-width: 0;
overflow-x: clip;
opacity: 0;
transition: opacity .3s, max-width 0s 9s;
}
:host(:hover) media-tooltip,
:host(:focus-visible) media-tooltip {
max-width: 100vw;
opacity: 1;
transition: opacity .3s;
}
:host([notooltip]) slot[name="tooltip"] {
display: none;
}
</style>
<slot name="tooltip">
<media-tooltip part="tooltip" aria-hidden="true">
<slot name="tooltip-content"></slot>
</media-tooltip>
</slot>
`;
/**
* @extends {HTMLElement}
*
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
* @attr {('top'|'right'|'bottom'|'left'|'none')} tooltipplacement - The placement of the tooltip, defaults to "top"
* @attr {boolean} notooltip - Hides the tooltip if this attribute is present
*
* @cssproperty --media-primary-color - Default color of text and icon.
* @cssproperty --media-secondary-color - Default color of button background.
* @cssproperty --media-text-color - `color` of button text.
* @cssproperty --media-icon-color - `fill` color of button icon.
*
* @cssproperty --media-control-display - `display` property of control.
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-control-hover-background - `background` of control hover state.
* @cssproperty --media-control-padding - `padding` of control.
* @cssproperty --media-control-height - `line-height` of control.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of button text.
*
* @cssproperty --media-button-icon-width - `width` of button icon.
* @cssproperty --media-button-icon-height - `height` of button icon.
* @cssproperty --media-button-icon-transform - `transform` of button icon.
* @cssproperty --media-button-icon-transition - `transition` of button icon.
*/
class MediaChromeButton extends globalThis.HTMLElement {
#mediaController;
preventClick = false;
nativeEl: DocumentFragment;
tooltipEl: MediaTooltip = null;
tooltipContent: string = '';
static get observedAttributes() {
return [
'disabled',
Attributes.TOOLTIP_PLACEMENT,
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
];
}
constructor(
options: Partial<{
slotTemplate: HTMLTemplateElement;
defaultContent: string;
tooltipContent: string;
}> = {}
) {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
const buttonHTML = template.content.cloneNode(true) as DocumentFragment;
this.nativeEl = buttonHTML;
// Slots
let slotTemplate = options.slotTemplate;
if (!slotTemplate) {
slotTemplate = document.createElement('template');
slotTemplate.innerHTML = `<slot>${options.defaultContent || ''}</slot>`;
}
if (options.tooltipContent) {
buttonHTML.querySelector('slot[name="tooltip-content"]').innerHTML =
options.tooltipContent ?? '';
this.tooltipContent = options.tooltipContent;
}
this.nativeEl.appendChild(slotTemplate.content.cloneNode(true));
this.shadowRoot.appendChild(buttonHTML);
}
this.tooltipEl = this.shadowRoot.querySelector('media-tooltip');
}
#clickListener = (e) => {
if (!this.preventClick) {
this.handleClick(e);
}
// Timeout needed to wait for a new "tick" of event loop otherwise
// measured position does not take into account the new tooltip content
setTimeout(this.#positionTooltip, 0);
};
#positionTooltip = () => {
// Conditional chaining accounts for scenarios
// where the tooltip element isn't yet defined.
this.tooltipEl?.updateXOffset?.();
}
// NOTE: There are definitely some "false positive" cases with multi-key pressing,
// but this should be good enough for most use cases.
#keyupListener = (e) => {
const { key } = e;
if (!this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#keyupListener);
return;
}
if (!this.preventClick) {
this.handleClick(e);
}
};
#keydownListener = (e) => {
const { metaKey, altKey, key } = e;
if (metaKey || altKey || !this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#keyupListener);
return;
}
this.addEventListener('keyup', this.#keyupListener, { once: true });
};
enable() {
this.addEventListener('click', this.#clickListener);
this.addEventListener('keydown', this.#keydownListener);
this.tabIndex = 0;
}
disable() {
this.removeEventListener('click', this.#clickListener);
this.removeEventListener('keydown', this.#keydownListener);
this.removeEventListener('keyup', this.#keyupListener);
this.tabIndex = -1;
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
} else if (attrName === 'disabled' && newValue !== oldValue) {
if (newValue == null) {
this.enable();
} else {
this.disable();
}
} else if (
attrName === Attributes.TOOLTIP_PLACEMENT &&
this.tooltipEl &&
newValue !== oldValue
) {
this.tooltipEl.placement = newValue;
}
// The tooltips label, and subsequently it's size and position, are a function
// of the buttons state, so we greedily assume we need account for any form
// of state change by reacting to all attribute changes, even if sometimes the
// update might be redundant
this.#positionTooltip();
}
connectedCallback() {
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.setProperty(
'display',
`var(--media-control-display, var(--${this.localName}-display, inline-flex))`
);
if (!this.hasAttribute('disabled')) {
this.enable();
}
this.setAttribute('role', 'button');
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
this.#mediaController =
// @ts-ignore
this.getRootNode()?.getElementById(mediaControllerId);
this.#mediaController?.associateElement?.(this);
}
globalThis.customElements
.whenDefined('media-tooltip')
.then(() => this.#setupTooltip());
}
// Called when we know the tooltip is ready / defined
#setupTooltip() {
this.addEventListener('mouseenter', this.#positionTooltip);
this.addEventListener('focus', this.#positionTooltip);
this.addEventListener('click', this.#clickListener);
const initialPlacement = this.tooltipPlacement;
if (initialPlacement && this.tooltipEl) {
this.tooltipEl.placement = initialPlacement;
}
}
disconnectedCallback() {
this.disable();
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
this.removeEventListener('mouseenter', this.#positionTooltip);
this.removeEventListener('focus', this.#positionTooltip);
this.removeEventListener('click', this.#clickListener);
}
get keysUsed() {
return ['Enter', ' '];
}
/**
* Get or set tooltip placement
*/
get tooltipPlacement(): TooltipPlacement | undefined {
return getStringAttr(this, Attributes.TOOLTIP_PLACEMENT);
}
set tooltipPlacement(value: TooltipPlacement | undefined) {
setStringAttr(this, Attributes.TOOLTIP_PLACEMENT, value);
}
/**
* @abstract
* @argument {Event} e
*/
handleClick(e) {} // eslint-disable-line
}
if (!globalThis.customElements.get('media-chrome-button')) {
globalThis.customElements.define('media-chrome-button', MediaChromeButton);
}
export { MediaChromeButton };
export default MediaChromeButton;

View File

@@ -0,0 +1,230 @@
import { globalThis, document } from './utils/server-safe-globals.js';
import {
containsComposedNode,
getActiveElement,
} from './utils/element-utils.js';
import { InvokeEvent } from './utils/events.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
font: var(--media-font,
var(--media-font-weight, normal)
var(--media-font-size, 14px) /
var(--media-text-content-height, var(--media-control-height, 24px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
background: var(--media-dialog-background, var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .8))));
border-radius: var(--media-dialog-border-radius);
border: var(--media-dialog-border, none);
display: var(--media-dialog-display, inline-flex);
transition: var(--media-dialog-transition-in,
visibility 0s,
opacity .2s ease-out,
transform .15s ease-out
) !important;
${/* ^^Prevent transition override by media-container */ ''}
visibility: var(--media-dialog-visibility, visible);
opacity: var(--media-dialog-opacity, 1);
transform: var(--media-dialog-transform-in, translateY(0) scale(1));
}
:host([hidden]) {
transition: var(--media-dialog-transition-out,
visibility .15s ease-in,
opacity .15s ease-in,
transform .15s ease-in
) !important;
visibility: var(--media-dialog-hidden-visibility, hidden);
opacity: var(--media-dialog-hidden-opacity, 0);
transform: var(--media-dialog-transform-out, translateY(2px) scale(.99));
pointer-events: none;
}
</style>
<slot></slot>
`;
export const Attributes = {
HIDDEN: 'hidden',
ANCHOR: 'anchor',
};
/**
* @extends {HTMLElement}
*
* @slot - Default slotted elements.
*
* @cssproperty --media-primary-color - Default color of text / icon.
* @cssproperty --media-secondary-color - Default color of background.
* @cssproperty --media-text-color - `color` of text.
*
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-dialog-display - `display` of dialog.
* @cssproperty --media-dialog-background - `background` of dialog.
* @cssproperty --media-dialog-border-radius - `border-radius` of dialog.
* @cssproperty --media-dialog-border - `border` of dialog.
* @cssproperty --media-dialog-transition-in - `transition` of dialog when showing.
* @cssproperty --media-dialog-transition-out - `transition` of dialog when hiding.
* @cssproperty --media-dialog-visibility - `visibility` of dialog when showing.
* @cssproperty --media-dialog-hidden-visibility - `visibility` of dialog when hiding.
* @cssproperty --media-dialog-opacity - `opacity` of dialog when showing.
* @cssproperty --media-dialog-hidden-opacity - `opacity` of dialog when hiding.
* @cssproperty --media-dialog-transform-in - `transform` of dialog when showing.
* @cssproperty --media-dialog-transform-out - `transform` of dialog when hiding.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of text.
*/
class MediaChromeDialog extends globalThis.HTMLElement {
static template: HTMLTemplateElement = template;
static get observedAttributes() {
return [Attributes.HIDDEN, Attributes.ANCHOR];
}
#previouslyFocused: HTMLElement | null = null;
#invokerElement: HTMLElement | null = null;
nativeEl: HTMLElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.nativeEl = (
this.constructor as typeof MediaChromeDialog
).template.content.cloneNode(true) as HTMLElement;
this.shadowRoot.append(this.nativeEl);
}
this.addEventListener('invoke', this);
this.addEventListener('focusout', this);
this.addEventListener('keydown', this);
}
handleEvent(event: Event) {
switch (event.type) {
case 'invoke':
this.#handleInvoke(event as InvokeEvent);
break;
case 'focusout':
this.#handleFocusOut(event as FocusEvent);
break;
case 'keydown':
this.#handleKeyDown(event as KeyboardEvent);
break;
}
}
connectedCallback(): void {
if (!this.role) {
this.role = 'dialog';
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
) {
if (attrName === Attributes.HIDDEN && newValue !== oldValue) {
if (this.hidden) {
this.#handleClosed();
} else {
this.#handleOpen();
}
}
}
#handleOpen() {
this.#invokerElement?.setAttribute('aria-expanded', 'true');
// Focus when the transition ends.
this.addEventListener('transitionend', () => this.focus(), { once: true });
}
#handleClosed() {
this.#invokerElement?.setAttribute('aria-expanded', 'false');
}
focus() {
this.#previouslyFocused = getActiveElement();
const focusable: HTMLElement | null = this.querySelector(
'[autofocus], [tabindex]:not([tabindex="-1"]), [role="menu"]'
);
focusable?.focus();
}
#handleInvoke(event: InvokeEvent) {
this.#invokerElement = event.relatedTarget as HTMLElement;
if (!containsComposedNode(this, event.relatedTarget as Node)) {
this.hidden = !this.hidden;
}
}
#handleFocusOut(event: FocusEvent) {
if (!containsComposedNode(this, event.relatedTarget as Node)) {
this.#previouslyFocused?.focus();
// If the menu was opened by a click, close it when selecting an item.
if (
this.#invokerElement &&
this.#invokerElement !== event.relatedTarget &&
!this.hidden
) {
this.hidden = true;
}
}
}
get keysUsed() {
return ['Escape', 'Tab'];
}
#handleKeyDown(event: KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey } = event;
if (ctrlKey || altKey || metaKey) {
return;
}
if (!this.keysUsed.includes(key)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (key === 'Tab') {
// Move focus to the previous focusable element.
if (event.shiftKey) {
(this.previousElementSibling as HTMLElement)?.focus?.();
} else {
// Move focus to the next focusable element.
(this.nextElementSibling as HTMLElement)?.focus?.();
}
// Go back to the previous focused element.
this.blur();
} else if (key === 'Escape') {
// Go back to the previous menu or close the menu.
this.#previouslyFocused?.focus();
this.hidden = true;
}
}
}
if (!globalThis.customElements.get('media-chrome-dialog')) {
globalThis.customElements.define('media-chrome-dialog', MediaChromeDialog);
}
export { MediaChromeDialog };
export default MediaChromeDialog;

View File

@@ -0,0 +1,625 @@
import { MediaStateReceiverAttributes } from './constants.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import {
getOrInsertCSSRule,
getPointProgressOnLine,
} from './utils/element-utils.js';
import { observeResize, unobserveResize } from './utils/resize-observer.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
--_focus-box-shadow: var(--media-focus-box-shadow, inset 0 0 0 2px rgb(27 127 204 / .9));
--_media-range-padding: var(--media-range-padding, var(--media-control-padding, 10px));
box-shadow: var(--_focus-visible-box-shadow, none);
background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7)));
height: calc(var(--media-control-height, 24px) + 2 * var(--_media-range-padding));
display: inline-flex;
align-items: center;
${
/* Don't horizontal align w/ justify-content! #container can go negative on the x-axis w/ small width. */ ''
}
vertical-align: middle;
box-sizing: border-box;
position: relative;
width: 100px;
transition: background .15s linear;
cursor: pointer;
pointer-events: auto;
touch-action: none; ${/* Prevent scrolling when dragging on mobile. */ ''}
z-index: 1; ${/* Apply z-index to overlap buttons below. */ ''}
}
${/* Reset before `outline` on track could be set by a CSS var */ ''}
input[type=range]:focus {
outline: 0;
}
input[type=range]:focus::-webkit-slider-runnable-track {
outline: 0;
}
:host(:hover) {
background: var(--media-control-hover-background, rgb(50 50 70 / .7));
}
#leftgap {
padding-left: var(--media-range-padding-left, var(--_media-range-padding));
}
#rightgap {
padding-right: var(--media-range-padding-right, var(--_media-range-padding));
}
#startpoint,
#endpoint {
position: absolute;
}
#endpoint {
right: 0;
}
#container {
${
/* Not using the CSS `padding` prop makes it easier for slide open volume ranges so the width can be zero. */ ''
}
width: var(--media-range-track-width, 100%);
transform: translate(var(--media-range-track-translate-x, 0px), var(--media-range-track-translate-y, 0px));
position: relative;
height: 100%;
display: flex;
align-items: center;
min-width: 40px;
}
#range {
${/* The input range acts as a hover and hit zone for input events. */ ''}
display: var(--media-time-range-hover-display, block);
bottom: var(--media-time-range-hover-bottom, -7px);
height: var(--media-time-range-hover-height, max(100% + 7px, 25px));
width: 100%;
position: absolute;
cursor: pointer;
-webkit-appearance: none; ${
/* Hides the slider so that custom slider can be made */ ''
}
-webkit-tap-highlight-color: transparent;
background: transparent; ${/* Otherwise white in Chrome */ ''}
margin: 0;
z-index: 1;
}
@media (hover: hover) {
#range {
bottom: var(--media-time-range-hover-bottom, -5px);
height: var(--media-time-range-hover-height, max(100% + 5px, 20px));
}
}
${/* Special styling for WebKit/Blink */ ''}
${
/* Make thumb width/height small so it has no effect on range click position. */ ''
}
#range::-webkit-slider-thumb {
-webkit-appearance: none;
background: transparent;
width: .1px;
height: .1px;
}
${/* The thumb is not positioned relative to the track in Firefox */ ''}
#range::-moz-range-thumb {
background: transparent;
border: transparent;
width: .1px;
height: .1px;
}
#appearance {
height: var(--media-range-track-height, 4px);
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
position: absolute;
${/* Required for Safari to stop glitching track height on hover */ ''}
will-change: transform;
}
#track {
background: var(--media-range-track-background, rgb(255 255 255 / .2));
border-radius: var(--media-range-track-border-radius, 1px);
border: var(--media-range-track-border, none);
outline: var(--media-range-track-outline);
outline-offset: var(--media-range-track-outline-offset);
backdrop-filter: var(--media-range-track-backdrop-filter);
-webkit-backdrop-filter: var(--media-range-track-backdrop-filter);
box-shadow: var(--media-range-track-box-shadow, none);
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
#progress,
#pointer {
position: absolute;
height: 100%;
will-change: width;
}
#progress {
background: var(--media-range-bar-color, var(--media-primary-color, rgb(238 238 238)));
transition: var(--media-range-track-transition);
}
#pointer {
background: var(--media-range-track-pointer-background);
border-right: var(--media-range-track-pointer-border-right);
transition: visibility .25s, opacity .25s;
visibility: hidden;
opacity: 0;
}
@media (hover: hover) {
:host(:hover) #pointer {
transition: visibility .5s, opacity .5s;
visibility: visible;
opacity: 1;
}
}
#thumb {
width: var(--media-range-thumb-width, 10px);
height: var(--media-range-thumb-height, 10px);
margin-left: calc(var(--media-range-thumb-width, 10px) / -2);
border: var(--media-range-thumb-border, none);
border-radius: var(--media-range-thumb-border-radius, 10px);
background: var(--media-range-thumb-background, var(--media-primary-color, rgb(238 238 238)));
box-shadow: var(--media-range-thumb-box-shadow, 1px 1px 1px transparent);
transition: var(--media-range-thumb-transition);
transform: var(--media-range-thumb-transform, none);
opacity: var(--media-range-thumb-opacity, 1);
position: absolute;
left: 0;
cursor: pointer;
}
:host([disabled]) #thumb {
background-color: #777;
}
.segments #appearance {
height: var(--media-range-segment-hover-height, 7px);
}
#track {
clip-path: url(#segments-clipping);
}
#segments {
--segments-gap: var(--media-range-segments-gap, 2px);
position: absolute;
width: 100%;
height: 100%;
}
#segments-clipping {
transform: translateX(calc(var(--segments-gap) / 2));
}
#segments-clipping:empty {
display: none;
}
#segments-clipping rect {
height: var(--media-range-track-height, 4px);
y: calc((var(--media-range-segment-hover-height, 7px) - var(--media-range-track-height, 4px)) / 2);
transition: var(--media-range-segment-transition, transform .1s ease-in-out);
transform: var(--media-range-segment-transform, scaleY(1));
transform-origin: center;
}
</style>
<div id="leftgap"></div>
<div id="container">
<div id="startpoint"></div>
<div id="endpoint"></div>
<div id="appearance">
<div id="track" part="track">
<div id="pointer"></div>
<div id="progress" part="progress"></div>
</div>
<div id="thumb" part="thumb"></div>
<svg id="segments"><clipPath id="segments-clipping"></clipPath></svg>
</div>
<input id="range" type="range" min="0" max="1" step="any" value="0">
</div>
<div id="rightgap"></div>
`;
/**
* @extends {HTMLElement}
*
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
*
* @csspart track - The runnable track of the range.
* @csspart progress - The progress part of the track.
* @csspart thumb - The thumb of the range.
*
* @cssproperty --media-primary-color - Default color of range bar.
* @cssproperty --media-secondary-color - Default color of range background.
*
* @cssproperty [--media-control-display = inline-block] - `display` property of control.
* @cssproperty --media-control-padding - `padding` of control.
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-control-hover-background - `background` of control hover state.
* @cssproperty --media-control-height - `height` of control.
*
* @cssproperty --media-range-padding - `padding` of range.
* @cssproperty --media-range-padding-left - `padding-left` of range.
* @cssproperty --media-range-padding-right - `padding-right` of range.
*
* @cssproperty --media-range-thumb-width - `width` of range thumb.
* @cssproperty --media-range-thumb-height - `height` of range thumb.
* @cssproperty --media-range-thumb-border - `border` of range thumb.
* @cssproperty --media-range-thumb-border-radius - `border-radius` of range thumb.
* @cssproperty --media-range-thumb-background - `background` of range thumb.
* @cssproperty --media-range-thumb-box-shadow - `box-shadow` of range thumb.
* @cssproperty --media-range-thumb-transition - `transition` of range thumb.
* @cssproperty --media-range-thumb-transform - `transform` of range thumb.
* @cssproperty --media-range-thumb-opacity - `opacity` of range thumb.
*
* @cssproperty [--media-range-bar-color = var(--media-primary-color, rgb(238 238 238))] - `background` of range progress.
* @cssproperty --media-range-track-background - `background` of range track background.
* @cssproperty --media-range-track-backdrop-filter - `backdrop-filter` of range track.
* @cssproperty --media-range-track-width - `width` of range track.
* @cssproperty --media-range-track-height - `height` of range track.
* @cssproperty --media-range-track-border - `border` of range track.
* @cssproperty --media-range-track-outline - `outline` of range track.
* @cssproperty --media-range-track-outline-offset - `outline-offset` of range track.
* @cssproperty --media-range-track-border-radius - `border-radius` of range track.
* @cssproperty --media-range-track-box-shadow - `box-shadow` of range track.
* @cssproperty --media-range-track-transition - `transition` of range track.
* @cssproperty --media-range-track-translate-x - `translate` x-coordinate of range track.
* @cssproperty --media-range-track-translate-y - `translate` y-coordinate of range track.
*
* @cssproperty --media-time-range-hover-display - `display` of range hover zone.
* @cssproperty --media-time-range-hover-bottom - `bottom` of range hover zone.
* @cssproperty --media-time-range-hover-height - `height` of range hover zone.
*
* @cssproperty --media-range-track-pointer-background - `background` of range track pointer.
* @cssproperty --media-range-track-pointer-border-right - `border-right` of range track pointer.
*/
class MediaChromeRange extends globalThis.HTMLElement {
#mediaController;
#isInputTarget;
#startpoint;
#endpoint;
#cssRules: Record<string, CSSStyleRule> = {};
#segments = [];
static get observedAttributes(): string[] {
return [
'disabled',
'aria-disabled',
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
];
}
container: HTMLElement;
range: HTMLInputElement;
appearance: HTMLElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
this.container = this.shadowRoot.querySelector('#container');
this.#startpoint = this.shadowRoot.querySelector('#startpoint');
this.#endpoint = this.shadowRoot.querySelector('#endpoint');
/** @type {Omit<HTMLInputElement, "value" | "min" | "max"> &
* {value: number, min: number, max: number}} */
this.range = this.shadowRoot.querySelector('#range');
this.appearance = this.shadowRoot.querySelector('#appearance');
}
#onFocusIn = (): void => {
if (this.range.matches(':focus-visible')) {
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.setProperty(
'--_focus-visible-box-shadow',
'var(--_focus-box-shadow)'
);
}
};
#onFocusOut = (): void => {
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.removeProperty('--_focus-visible-box-shadow');
};
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
} else if (
attrName === 'disabled' ||
(attrName === 'aria-disabled' && oldValue !== newValue)
) {
if (newValue == null) {
this.range.removeAttribute(attrName);
this.#enableUserEvents();
} else {
this.range.setAttribute(attrName, newValue);
this.#disableUserEvents();
}
}
}
connectedCallback(): void {
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.setProperty(
'display',
`var(--media-control-display, var(--${this.localName}-display, inline-flex))`
);
this.#cssRules.pointer = getOrInsertCSSRule(this.shadowRoot, '#pointer');
this.#cssRules.progress = getOrInsertCSSRule(this.shadowRoot, '#progress');
this.#cssRules.thumb = getOrInsertCSSRule(this.shadowRoot, '#thumb');
this.#cssRules.activeSegment = getOrInsertCSSRule(
this.shadowRoot,
'#segments-clipping rect:nth-child(0)'
);
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
// @ts-ignore
this.#mediaController = (this.getRootNode() as Document)?.getElementById(
mediaControllerId
);
this.#mediaController?.associateElement?.(this);
}
this.updateBar();
this.shadowRoot.addEventListener('focusin', this.#onFocusIn);
this.shadowRoot.addEventListener('focusout', this.#onFocusOut);
this.#enableUserEvents();
observeResize(this.container, this.#updateComputedStyles);
}
disconnectedCallback(): void {
this.#disableUserEvents();
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
this.shadowRoot.removeEventListener('focusin', this.#onFocusIn);
this.shadowRoot.removeEventListener('focusout', this.#onFocusOut);
unobserveResize(this.container, this.#updateComputedStyles);
}
#updateComputedStyles = () => {
// This fixes a Chrome bug where it doesn't refresh the clip-path on content resize.
const clipping = this.shadowRoot.querySelector('#segments-clipping');
if (clipping) clipping.parentNode.append(clipping);
};
updatePointerBar(evt) {
this.#cssRules.pointer?.style.setProperty(
'width',
`${this.getPointerRatio(evt) * 100}%`
);
}
updateBar() {
const rangePercent = this.range.valueAsNumber * 100;
this.#cssRules.progress?.style.setProperty('width', `${rangePercent}%`);
this.#cssRules.thumb?.style.setProperty('left', `${rangePercent}%`);
}
updateSegments(segments) {
const clipping = this.shadowRoot.querySelector('#segments-clipping');
clipping.textContent = '';
this.container.classList.toggle('segments', !!segments?.length);
if (!segments?.length) return;
const normalized = [
...new Set([
+this.range.min,
...segments.flatMap((s) => [s.start, s.end]),
+this.range.max,
]),
];
this.#segments = [...normalized];
const lastMarker = normalized.pop();
for (const [i, marker] of normalized.entries()) {
const [isFirst, isLast] = [i === 0, i === normalized.length - 1];
const x = isFirst ? 'calc(var(--segments-gap) / -1)' : `${marker * 100}%`;
const x2 = isLast ? lastMarker : normalized[i + 1];
const width = `calc(${(x2 - marker) * 100}%${
isFirst || isLast ? '' : ` - var(--segments-gap)`
})`;
const segmentEl = document.createElementNS(
'http://www.w3.org/2000/svg',
'rect'
);
const cssRule = getOrInsertCSSRule(
this.shadowRoot,
`#segments-clipping rect:nth-child(${i + 1})`
);
cssRule.style.setProperty('x', x);
cssRule.style.setProperty('width', width);
clipping.append(segmentEl);
}
}
#updateActiveSegment(evt) {
const rule = this.#cssRules.activeSegment;
if (!rule) return;
const pointerRatio = this.getPointerRatio(evt);
const segmentIndex = this.#segments.findIndex((start, i, arr) => {
const end = arr[i + 1];
return end != null && pointerRatio >= start && pointerRatio <= end;
});
const selectorText = `#segments-clipping rect:nth-child(${
segmentIndex + 1
})`;
if (rule.selectorText != selectorText || !rule.style.transform) {
rule.selectorText = selectorText;
rule.style.setProperty(
'transform',
'var(--media-range-segment-hover-transform, scaleY(2))'
);
}
}
getPointerRatio(evt) {
const pointerRatio = getPointProgressOnLine(
evt.clientX,
evt.clientY,
this.#startpoint.getBoundingClientRect(),
this.#endpoint.getBoundingClientRect()
);
return Math.max(0, Math.min(1, pointerRatio));
}
get dragging() {
return this.hasAttribute('dragging');
}
#enableUserEvents() {
if (this.hasAttribute('disabled')) return;
this.addEventListener('input', this);
this.addEventListener('pointerdown', this);
this.addEventListener('pointerenter', this);
}
#disableUserEvents() {
this.removeEventListener('input', this);
this.removeEventListener('pointerdown', this);
this.removeEventListener('pointerenter', this);
globalThis.window?.removeEventListener('pointerup', this);
globalThis.window?.removeEventListener('pointermove', this);
}
handleEvent(evt) {
switch (evt.type) {
case 'pointermove':
this.#handlePointerMove(evt);
break;
case 'input':
this.updateBar();
break;
case 'pointerenter':
this.#handlePointerEnter(evt);
break;
case 'pointerdown':
this.#handlePointerDown(evt);
break;
case 'pointerup':
this.#handlePointerUp();
break;
case 'pointerleave':
this.#handlePointerLeave();
break;
}
}
#handlePointerDown(evt) {
// Events outside the range element are handled manually below.
this.#isInputTarget = evt.composedPath().includes(this.range);
globalThis.window?.addEventListener('pointerup', this);
}
#handlePointerEnter(evt) {
// On mobile a pointerdown is not required to drag the range.
if (evt.pointerType !== 'mouse') this.#handlePointerDown(evt);
this.addEventListener('pointerleave', this);
globalThis.window?.addEventListener('pointermove', this);
}
#handlePointerUp() {
globalThis.window?.removeEventListener('pointerup', this);
this.toggleAttribute('dragging', false);
this.range.disabled = this.hasAttribute('disabled');
}
#handlePointerLeave() {
this.removeEventListener('pointerleave', this);
globalThis.window?.removeEventListener('pointermove', this);
this.toggleAttribute('dragging', false);
this.range.disabled = this.hasAttribute('disabled');
this.#cssRules.activeSegment?.style.removeProperty('transform');
}
#handlePointerMove(evt) {
this.toggleAttribute(
'dragging',
evt.buttons === 1 || evt.pointerType !== 'mouse'
);
this.updatePointerBar(evt);
this.#updateActiveSegment(evt);
// If the native input target & events are used don't fire manual input events.
if (
this.dragging &&
(evt.pointerType !== 'mouse' || !this.#isInputTarget)
) {
// Disable native input events if manual events are fired.
this.range.disabled = true;
this.range.valueAsNumber = this.getPointerRatio(evt);
this.range.dispatchEvent(
new Event('input', { bubbles: true, composed: true })
);
}
}
get keysUsed() {
return ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'];
}
}
if (!globalThis.customElements.get('media-chrome-range')) {
globalThis.customElements.define('media-chrome-range', MediaChromeRange);
}
export { MediaChromeRange };
export default MediaChromeRange;

View File

@@ -0,0 +1,651 @@
/*
The <media-chrome> can contain the control elements
and the media element. Features:
* Auto-set the `media` attribute on child media chrome elements
* Uses the element with slot="media"
* Take custom controls to fullscreen
* Position controls at the bottom
* Auto-hide controls on inactivity while playing
*/
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIAttributes, MediaStateChangeEvents } from './constants.js';
import { nouns } from './labels/labels.js';
import { observeResize } from './utils/resize-observer.js';
// Guarantee that `<media-gesture-receiver/>` is available for use in the template
import './media-gesture-receiver.js';
export const Attributes = {
AUDIO: 'audio',
AUTOHIDE: 'autohide',
BREAKPOINTS: 'breakpoints',
GESTURES_DISABLED: 'gesturesdisabled',
KEYBOARD_CONTROL: 'keyboardcontrol',
NO_AUTOHIDE: 'noautohide',
USER_INACTIVE: 'userinactive',
};
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
${
/*
* outline on media is turned off because it is allowed to get focus to faciliate hotkeys.
* However, on keyboard interactions, the focus outline is shown,
* which is particularly noticeable when going fullscreen via hotkeys.
*/ ''
}
:host([${MediaUIAttributes.MEDIA_IS_FULLSCREEN}]) ::slotted([slot=media]) {
outline: none;
}
:host {
box-sizing: border-box;
position: relative;
display: inline-block;
line-height: 0;
background-color: var(--media-background-color, #000);
}
:host(:not([${Attributes.AUDIO}])) [part~=layer]:not([part~=media-layer]) {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
flex-flow: column nowrap;
align-items: start;
pointer-events: none;
background: none;
}
slot[name=media] {
display: var(--media-slot-display, contents);
}
${
/*
* when in audio mode, hide the slotted media element by default
*/ ''
}
:host([${Attributes.AUDIO}]) slot[name=media] {
display: var(--media-slot-display, none);
}
${
/*
* when in audio mode, hide the gesture-layer which causes media-controller to be taller than the control bar
*/ ''
}
:host([${Attributes.AUDIO}]) [part~=layer][part~=gesture-layer] {
height: 0;
display: block;
}
${
/*
* if gestures are disabled, don't accept pointer-events
*/ ''
}
:host(:not([${Attributes.AUDIO}])[${
Attributes.GESTURES_DISABLED
}]) ::slotted([slot=gestures-chrome]),
:host(:not([${Attributes.AUDIO}])[${
Attributes.GESTURES_DISABLED
}]) media-gesture-receiver[slot=gestures-chrome] {
display: none;
}
${
/*
* any slotted element that isn't a poster or media slot should be pointer-events auto
* we'll want to add here any slotted elements that shouldn't get pointer-events by default when slotted
*/ ''
}
::slotted(:not([slot=media]):not([slot=poster]):not(media-loading-indicator):not([hidden])) {
pointer-events: auto;
}
:host(:not([${Attributes.AUDIO}])) *[part~=layer][part~=centered-layer] {
align-items: center;
justify-content: center;
}
:host(:not([${
Attributes.AUDIO
}])) ::slotted(media-gesture-receiver[slot=gestures-chrome]),
:host(:not([${
Attributes.AUDIO
}])) media-gesture-receiver[slot=gestures-chrome] {
align-self: stretch;
flex-grow: 1;
}
slot[name=middle-chrome] {
display: inline;
flex-grow: 1;
pointer-events: none;
background: none;
}
${/* Position the media and poster elements to fill the container */ ''}
::slotted([slot=media]),
::slotted([slot=poster]) {
width: 100%;
height: 100%;
}
${/* Video specific styles */ ''}
:host(:not([${Attributes.AUDIO}])) .spacer {
flex-grow: 1;
}
${/* Safari needs this to actually make the element fill the window */ ''}
:host(:-webkit-full-screen) {
${/* Needs to use !important otherwise easy to break */ ''}
width: 100% !important;
height: 100% !important;
}
${/* Only add these if auto hide is not disabled */ ''}
::slotted(:not([slot=media]):not([slot=poster]):not([${
Attributes.NO_AUTOHIDE
}]):not([hidden])) {
opacity: 1;
transition: opacity 0.25s;
}
${
/* Hide controls when inactive, not paused, not audio and auto hide not disabled */ ''
}
:host([${Attributes.USER_INACTIVE}]:not([${
MediaUIAttributes.MEDIA_PAUSED
}]):not([${MediaUIAttributes.MEDIA_IS_AIRPLAYING}]):not([${
MediaUIAttributes.MEDIA_IS_CASTING
}]):not([${
Attributes.AUDIO
}])) ::slotted(:not([slot=media]):not([slot=poster]):not([${
Attributes.NO_AUTOHIDE
}])) {
opacity: 0;
transition: opacity 1s;
}
:host([${Attributes.USER_INACTIVE}]:not([${
MediaUIAttributes.MEDIA_PAUSED
}]):not([${MediaUIAttributes.MEDIA_IS_CASTING}]):not([${
Attributes.AUDIO
}])) ::slotted([slot=media]) {
cursor: none;
}
::slotted(media-control-bar) {
align-self: stretch;
}
${
/* ::slotted([slot=poster]) doesn't work for slot fallback content so hide parent slot instead */ ''
}
:host(:not([${Attributes.AUDIO}])[${
MediaUIAttributes.MEDIA_HAS_PLAYED
}]) slot[name=poster] {
display: none;
}
::slotted([role="menu"]) {
align-self: end;
}
::slotted([role="dialog"]) {
align-self: center;
}
</style>
<slot name="media" part="layer media-layer"></slot>
<slot name="poster" part="layer poster-layer"></slot>
<slot name="gestures-chrome" part="layer gesture-layer">
<media-gesture-receiver slot="gestures-chrome"></media-gesture-receiver>
</slot>
<span part="layer vertical-layer">
<slot name="top-chrome" part="top chrome"></slot>
<slot name="middle-chrome" part="middle chrome"></slot>
<slot name="centered-chrome" part="layer centered-layer center centered chrome"></slot>
${/* default, effectively "bottom-chrome" */ ''}
<slot part="bottom chrome"></slot>
</span>
`;
const MEDIA_UI_ATTRIBUTE_NAMES = Object.values(MediaUIAttributes);
const defaultBreakpoints = 'sm:384 md:576 lg:768 xl:960';
function resizeCallback(entry: ResizeObserverEntry) {
setBreakpoints(entry.target as HTMLElement, entry.contentRect.width);
}
function setBreakpoints(container: HTMLElement, width: number) {
if (!container.isConnected) return;
const breakpoints =
container.getAttribute(Attributes.BREAKPOINTS) ?? defaultBreakpoints;
const ranges = createBreakpointMap(breakpoints);
const activeBreakpoints = getBreakpoints(ranges, width);
let changed = false;
Object.keys(ranges).forEach((name) => {
if (activeBreakpoints.includes(name)) {
if (!container.hasAttribute(`breakpoint${name}`)) {
container.setAttribute(`breakpoint${name}`, '');
changed = true;
}
return;
}
if (container.hasAttribute(`breakpoint${name}`)) {
container.removeAttribute(`breakpoint${name}`);
changed = true;
}
});
if (changed) {
const evt = new CustomEvent(MediaStateChangeEvents.BREAKPOINTS_CHANGE, {
detail: activeBreakpoints,
});
container.dispatchEvent(evt);
}
}
function createBreakpointMap(breakpoints: string) {
const pairs = breakpoints.split(/\s+/);
return Object.fromEntries(pairs.map((pair) => pair.split(':')));
}
function getBreakpoints(breakpoints: Record<string, string>, width: number) {
return Object.keys(breakpoints).filter((name) => {
return width >= parseInt(breakpoints[name]);
});
}
/**
* @extends {HTMLElement}
*
* @attr {boolean} audio
* @attr {string} autohide
* @attr {string} breakpoints
* @attr {boolean} gesturesdisabled
* @attr {boolean} keyboardcontrol
* @attr {boolean} noautohide
* @attr {boolean} userinactive
*
* @cssprop --media-background-color - `background-color` of container.
* @cssprop --media-slot-display - `display` of the media slot (default none for [audio] usage).
*/
class MediaContainer extends globalThis.HTMLElement {
static get observedAttributes(): string[] {
return (
[Attributes.AUTOHIDE, Attributes.GESTURES_DISABLED]
.concat(MEDIA_UI_ATTRIBUTE_NAMES)
// Filter out specific / complex data media UI attributes
// that shouldn't be propagated to this state receiver element.
.filter(
(name) =>
![
MediaUIAttributes.MEDIA_RENDITION_LIST,
MediaUIAttributes.MEDIA_AUDIO_TRACK_LIST,
MediaUIAttributes.MEDIA_CHAPTERS_CUES,
MediaUIAttributes.MEDIA_WIDTH,
MediaUIAttributes.MEDIA_HEIGHT,
].includes(name as any)
)
);
}
#pointerDownTimeStamp = 0;
#currentMedia: HTMLMediaElement | null = null;
#inactiveTimeout: ReturnType<typeof setTimeout> | null = null;
#autohide: number | undefined;
breakpointsComputed = false;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
// Watch for child adds/removes and update the media element if necessary
const mutationCallback = (mutationsList: MutationRecord[]) => {
const media = this.media;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
// Media element being removed
mutation.removedNodes.forEach((node: Element) => {
// Is this a direct child media element of media-controller?
// TODO: This accuracy doesn't matter after moving away from media attrs.
// Could refactor so we can always just call 'dispose' on any removed media el.
if (node.slot == 'media' && mutation.target == this) {
// Check if this was the current media by if it was the first
// el with slot=media in the child list. There could be multiple.
let previousSibling =
mutation.previousSibling &&
(mutation.previousSibling as Element).previousElementSibling;
// Must have been first if no prev sibling or new media
if (!previousSibling || !media) {
this.mediaUnsetCallback(node as HTMLMediaElement);
} else {
// Check if any prev siblings had a slot=media
// Should remain true otherwise
let wasFirst = previousSibling.slot !== 'media';
while (
(previousSibling =
previousSibling.previousSibling as Element) !== null
) {
if (previousSibling.slot == 'media') wasFirst = false;
}
if (wasFirst) this.mediaUnsetCallback(node as HTMLMediaElement);
}
}
});
// Controls or media element being added
// No need to inject anything if media=null
if (media) {
mutation.addedNodes.forEach((node) => {
if (node === media) {
// Update all controls with new media if this is the new media
this.handleMediaUpdated(media);
}
});
}
}
}
};
const mutationObserver = new MutationObserver(mutationCallback);
mutationObserver.observe(this, { childList: true, subtree: true });
let pendingResizeCb = false;
const deferResizeCallback = (entry: ResizeObserverEntry) => {
// Already have a pending async breakpoint computation, so go ahead and bail
if (pendingResizeCb) return;
// Just in case it takes too long (which will cause an error to throw),
// do the breakpoint computation asynchronously
setTimeout(() => {
resizeCallback(entry);
// Once we've completed, reset the pending cb flag to false
pendingResizeCb = false;
if (!this.breakpointsComputed) {
this.breakpointsComputed = true;
this.dispatchEvent(
new CustomEvent(MediaStateChangeEvents.BREAKPOINTS_COMPUTED, {
bubbles: true,
composed: true,
})
);
}
}, 0);
pendingResizeCb = true;
};
observeResize(this, deferResizeCallback);
// Handles the case when the slotted media element is a slot element itself.
// e.g. chaining media slots for media themes.
/** @type {HTMLSlotElement} */
const chainedSlot = this.querySelector(
':scope > slot[slot=media]'
) as HTMLSlotElement;
if (chainedSlot) {
chainedSlot.addEventListener('slotchange', () => {
const slotEls = chainedSlot.assignedElements({ flatten: true });
if (!slotEls.length) {
if (this.#currentMedia) {
this.mediaUnsetCallback(this.#currentMedia);
}
return;
}
this.handleMediaUpdated(this.media);
});
}
}
// Could share this code with media-chrome-html-element instead
attributeChangedCallback(
attrName: string,
oldValue: string,
newValue: string
) {
if (attrName.toLowerCase() == Attributes.AUTOHIDE) {
this.autohide = newValue;
}
}
// First direct child with slot=media, or null
/**
* @returns {HTMLVideoElement &
* {buffered,
* webkitEnterFullscreen?,
* webkitExitFullscreen?,
* requestCast?,
* webkitShowPlaybackTargetPicker?,
* videoTracks?,
* }}
*/
get media(): HTMLVideoElement | null {
/** @type {HTMLVideoElement} */
let media = this.querySelector(':scope > [slot=media]') as HTMLVideoElement;
// Chaining media slots for media templates
if (media?.nodeName == 'SLOT')
// @ts-ignore
media = media.assignedElements({ flatten: true })[0];
return media;
}
/**
* @param {HTMLMediaElement} media
*/
async handleMediaUpdated(media: HTMLMediaElement) {
// Anything "falsy" couldn't act as a media element.
if (!media) return;
this.#currentMedia = media;
// Custom element. Wait until it's defined before resolving
if (media.localName.includes('-')) {
await globalThis.customElements.whenDefined(media.localName);
}
// Even if we are not connected to the DOM after this await still call mediaSetCallback
// so the media state is already computed once, then when the container is connected
// to the DOM mediaSetCallback is called again to attach the root node event listeners.
this.mediaSetCallback(media);
}
connectedCallback(): void {
const isAudioChrome = this.getAttribute(Attributes.AUDIO) != null;
const label = isAudioChrome ? nouns.AUDIO_PLAYER() : nouns.VIDEO_PLAYER();
this.setAttribute('role', 'region');
this.setAttribute('aria-label', label);
this.handleMediaUpdated(this.media);
// Assume user is inactive until they're not (aka userinactive by default is true)
// This allows things like autoplay and programmatic playing to also initiate hiding controls (CJP)
this.setAttribute(Attributes.USER_INACTIVE, '');
this.addEventListener('pointerdown', this);
this.addEventListener('pointermove', this);
this.addEventListener('pointerup', this);
this.addEventListener('mouseleave', this);
this.addEventListener('keyup', this);
globalThis.window?.addEventListener('mouseup', this);
}
disconnectedCallback(): void {
// When disconnected from the DOM, remove root node and media event listeners
// to prevent memory leaks and unneeded invisble UI updates.
if (this.media) {
this.mediaUnsetCallback(this.media);
}
globalThis.window?.removeEventListener('mouseup', this);
}
/**
* @abstract
* @param {HTMLMediaElement} media
*/
mediaSetCallback(media: HTMLMediaElement) {} // eslint-disable-line
/**
* @param {HTMLMediaElement} media
*/
mediaUnsetCallback(
media: HTMLMediaElement // eslint-disable-line
) {
this.#currentMedia = null;
}
handleEvent(event: Event) {
switch (event.type) {
case 'pointerdown':
this.#pointerDownTimeStamp = (event as PointerEvent).timeStamp;
break;
case 'pointermove':
this.#handlePointerMove(event as PointerEvent);
break;
case 'pointerup':
this.#handlePointerUp(event as PointerEvent);
break;
case 'mouseleave':
// Immediately hide if mouse leaves the container.
this.#setInactive();
break;
case 'mouseup':
this.removeAttribute(Attributes.KEYBOARD_CONTROL);
break;
case 'keyup':
// Unhide for keyboard controlling.
this.#scheduleInactive();
// Allow for focus styles only when using the keyboard to navigate.
this.setAttribute(Attributes.KEYBOARD_CONTROL, '');
break;
}
}
#handlePointerMove(event: PointerEvent) {
if (event.pointerType !== 'mouse') {
// On mobile we toggle the controls on a tap which is handled in pointerup,
// but Android fires pointermove events even when the user is just tapping.
// Prevent calling setActive() on tap because it will mess with the toggle logic.
const MAX_TAP_DURATION = 250;
// If the move duration exceeds 200ms then it's a drag and we should show the controls.
if (event.timeStamp - this.#pointerDownTimeStamp < MAX_TAP_DURATION)
return;
}
this.#setActive();
// Stay visible if hovered over control bar
clearTimeout(this.#inactiveTimeout);
// If hovering over something other than controls, we're free to make inactive
// @ts-ignore
if ([this, this.media].includes(event.target)) {
this.#scheduleInactive();
}
}
#handlePointerUp(event: PointerEvent) {
if (event.pointerType === 'touch') {
const controlsVisible = !this.hasAttribute(Attributes.USER_INACTIVE);
if (
[this, this.media].includes(event.target as HTMLVideoElement) &&
controlsVisible
) {
this.#setInactive();
} else {
this.#scheduleInactive();
}
} else if (
event
.composedPath()
.some((el: HTMLElement) =>
['media-play-button', 'media-fullscreen-button'].includes(
el?.localName
)
)
) {
this.#scheduleInactive();
}
}
#setInactive() {
if (this.#autohide < 0) return;
if (this.hasAttribute(Attributes.USER_INACTIVE)) return;
this.setAttribute(Attributes.USER_INACTIVE, '');
const evt = new globalThis.CustomEvent(
MediaStateChangeEvents.USER_INACTIVE,
{ composed: true, bubbles: true, detail: true }
);
this.dispatchEvent(evt);
}
#setActive() {
if (!this.hasAttribute(Attributes.USER_INACTIVE)) return;
this.removeAttribute(Attributes.USER_INACTIVE);
const evt = new globalThis.CustomEvent(
MediaStateChangeEvents.USER_INACTIVE,
{ composed: true, bubbles: true, detail: false }
);
this.dispatchEvent(evt);
}
#scheduleInactive() {
this.#setActive();
clearTimeout(this.#inactiveTimeout);
const autohide = parseInt(this.autohide);
// Setting autohide to -1 turns off autohide
if (autohide < 0) return;
/** @type {ReturnType<typeof setTimeout>} */
this.#inactiveTimeout = setTimeout(() => {
this.#setInactive();
}, autohide * 1000);
}
set autohide(seconds: string) {
const parsedSeconds = Number(seconds);
this.#autohide = isNaN(parsedSeconds) ? 0 : parsedSeconds;
}
get autohide(): string {
return (this.#autohide === undefined ? 2 : this.#autohide).toString();
}
}
if (!globalThis.customElements.get('media-container')) {
globalThis.customElements.define('media-container', MediaContainer);
}
export { MediaContainer };
export default MediaContainer;

View File

@@ -0,0 +1,109 @@
/*
<media-control-bar>
Auto position contorls in a line and set some base colors
*/
import { MediaStateReceiverAttributes } from './constants.js';
import type MediaController from './media-controller.js';
import { document, globalThis } from './utils/server-safe-globals.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
${/* Need position to display above video for some reason */ ''}
box-sizing: border-box;
display: var(--media-control-display, var(--media-control-bar-display, inline-flex));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
--media-loading-indicator-icon-height: 44px;
}
::slotted(media-time-range),
::slotted(media-volume-range) {
min-height: 100%;
}
::slotted(media-time-range),
::slotted(media-clip-selector) {
flex-grow: 1;
}
::slotted([role="menu"]) {
position: absolute;
}
</style>
<slot></slot>
`;
/**
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
*
* @cssproperty --media-primary-color - Default color of text and icon.
* @cssproperty --media-secondary-color - Default color of button background.
* @cssproperty --media-text-color - `color` of button text.
*
* @cssproperty --media-control-bar-display - `display` property of control bar.
* @cssproperty --media-control-display - `display` property of control.
*/
class MediaControlBar extends globalThis.HTMLElement {
#mediaController: MediaController | null;
static get observedAttributes(): string[] {
return [MediaStateReceiverAttributes.MEDIA_CONTROLLER];
}
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
connectedCallback(): void {
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
// @ts-ignore
this.#mediaController = (this.getRootNode() as Document)?.getElementById(
mediaControllerId
);
this.#mediaController?.associateElement?.(this);
}
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
}
if (!globalThis.customElements.get('media-control-bar')) {
globalThis.customElements.define('media-control-bar', MediaControlBar);
}
export default MediaControlBar;

View File

@@ -0,0 +1,871 @@
/*
The <media-chrome> can contain the control elements
and the media element. Features:
* Auto-set the `media` attribute on child media chrome elements
* Uses the element with slot="media"
* Take custom controls to fullscreen
* Position controls at the bottom
* Auto-hide controls on inactivity while playing
*/
import { MediaContainer } from './media-container.js';
import { document, globalThis } from './utils/server-safe-globals.js';
import { AttributeTokenList } from './utils/attribute-token-list.js';
import {
delay,
stringifyRenditionList,
stringifyAudioTrackList,
} from './utils/utils.js';
import { stringifyTextTrackList } from './utils/captions.js';
import {
MediaUIEvents,
MediaUIAttributes,
MediaStateReceiverAttributes,
AttributeToStateChangeEventMap,
MediaUIProps,
} from './constants.js';
import {
setBooleanAttr,
setNumericAttr,
setStringAttr,
} from './utils/element-utils.js';
import createMediaStore, { MediaStore } from './media-store/media-store.js';
import { CustomElement } from './utils/CustomElement.js';
const ButtonPressedKeys = [
'ArrowLeft',
'ArrowRight',
'Enter',
' ',
'f',
'm',
'k',
'c',
];
const DEFAULT_SEEK_OFFSET = 10;
export const Attributes = {
DEFAULT_SUBTITLES: 'defaultsubtitles',
DEFAULT_STREAM_TYPE: 'defaultstreamtype',
DEFAULT_DURATION: 'defaultduration',
FULLSCREEN_ELEMENT: 'fullscreenelement',
HOTKEYS: 'hotkeys',
KEYS_USED: 'keysused',
LIVE_EDGE_OFFSET: 'liveedgeoffset',
NO_AUTO_SEEK_TO_LIVE: 'noautoseektolive',
NO_HOTKEYS: 'nohotkeys',
NO_VOLUME_PREF: 'novolumepref',
NO_SUBTITLES_LANG_PREF: 'nosubtitleslangpref',
NO_DEFAULT_STORE: 'nodefaultstore',
KEYBOARD_FORWARD_SEEK_OFFSET: 'keyboardforwardseekoffset',
KEYBOARD_BACKWARD_SEEK_OFFSET: 'keyboardbackwardseekoffset',
};
/**
* Media Controller should not mimic the HTMLMediaElement API.
* @see https://github.com/muxinc/media-chrome/pull/182#issuecomment-1067370339
*
* @attr {boolean} defaultsubtitles
* @attr {string} defaultstreamtype
* @attr {string} defaultduration
* @attr {string} fullscreenelement
* @attr {boolean} nohotkeys
* @attr {string} hotkeys
* @attr {string} keysused
* @attr {string} liveedgeoffset
* @attr {boolean} noautoseektolive
* @attr {boolean} novolumepref
* @attr {boolean} nosubtitleslangpref
* @attr {boolean} nodefaultstore
*/
class MediaController extends MediaContainer {
static get observedAttributes() {
return super.observedAttributes.concat(
Attributes.NO_HOTKEYS,
Attributes.HOTKEYS,
Attributes.DEFAULT_STREAM_TYPE,
Attributes.DEFAULT_SUBTITLES,
Attributes.DEFAULT_DURATION
);
}
mediaStateReceivers: HTMLElement[] = [];
associatedElementSubscriptions: Map<HTMLElement, () => void> = new Map();
#hotKeys = new AttributeTokenList(this, Attributes.HOTKEYS);
#fullscreenElement: HTMLElement;
#mediaStore: MediaStore;
#mediaStateCallback: (nextState: any) => void;
#mediaStoreUnsubscribe: () => void;
#mediaStateEventHandler = (event): void => {
this.#mediaStore?.dispatch(event);
};
constructor() {
super();
// Track externally associated control elements
this.associateElement(this);
let prevState = {};
this.#mediaStateCallback = (nextState: any): void => {
Object.entries(nextState).forEach(([stateName, stateValue]) => {
// Make sure to propagate initial state, even if still undefined (CJP)
if (stateName in prevState && prevState[stateName] === stateValue)
return;
this.propagateMediaState(stateName, stateValue);
const attrName = stateName.toLowerCase();
const evt = new globalThis.CustomEvent(
AttributeToStateChangeEventMap[attrName],
{ composed: true, detail: stateValue }
);
this.dispatchEvent(evt);
});
prevState = nextState;
};
this.enableHotkeys();
}
#setupDefaultStore() {
this.mediaStore = createMediaStore({
media: this.media,
fullscreenElement: this.fullscreenElement,
options: {
defaultSubtitles: this.hasAttribute(Attributes.DEFAULT_SUBTITLES),
defaultDuration: this.hasAttribute(Attributes.DEFAULT_DURATION)
? +this.getAttribute(Attributes.DEFAULT_DURATION)
: undefined,
defaultStreamType:
/** @type {import('./media-store/state-mediator.js').StreamTypeValue} */ this.getAttribute(
Attributes.DEFAULT_STREAM_TYPE
) ?? undefined,
liveEdgeOffset: this.hasAttribute(Attributes.LIVE_EDGE_OFFSET)
? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET)
: undefined,
// NOTE: This wasn't updated if it was changed later. Should it be? (CJP)
noVolumePref: this.hasAttribute(Attributes.NO_VOLUME_PREF),
noSubtitlesLangPref: this.hasAttribute(
Attributes.NO_SUBTITLES_LANG_PREF
),
},
});
}
get mediaStore(): MediaStore {
return this.#mediaStore;
}
set mediaStore(value: MediaStore) {
if (this.#mediaStore) {
this.#mediaStoreUnsubscribe?.();
this.#mediaStoreUnsubscribe = undefined;
}
this.#mediaStore = value;
if (!this.#mediaStore && !this.hasAttribute(Attributes.NO_DEFAULT_STORE)) {
this.#setupDefaultStore();
return;
}
this.#mediaStoreUnsubscribe = this.#mediaStore?.subscribe(
this.#mediaStateCallback
);
}
get fullscreenElement(): HTMLElement {
return this.#fullscreenElement ?? this;
}
set fullscreenElement(element: HTMLElement) {
if (this.hasAttribute(Attributes.FULLSCREEN_ELEMENT)) {
this.removeAttribute(Attributes.FULLSCREEN_ELEMENT);
}
this.#fullscreenElement = element;
// Use the getter in case the fullscreen element was reset to "`this`"
this.#mediaStore?.dispatch({
type: 'fullscreenelementchangerequest',
detail: this.fullscreenElement,
});
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === Attributes.NO_HOTKEYS) {
if (newValue !== oldValue && newValue === '') {
if (this.hasAttribute(Attributes.HOTKEYS)) {
console.warn(
'Media Chrome: Both `hotkeys` and `nohotkeys` have been set. All hotkeys will be disabled.'
);
}
this.disableHotkeys();
} else if (newValue !== oldValue && newValue === null) {
this.enableHotkeys();
}
} else if (attrName === Attributes.HOTKEYS) {
this.#hotKeys.value = newValue;
} else if (
attrName === Attributes.DEFAULT_SUBTITLES &&
newValue !== oldValue
) {
this.#mediaStore?.dispatch({
type: 'optionschangerequest',
detail: {
defaultSubtitles: this.hasAttribute(Attributes.DEFAULT_SUBTITLES),
},
});
} else if (attrName === Attributes.DEFAULT_STREAM_TYPE) {
this.#mediaStore?.dispatch({
type: 'optionschangerequest',
detail: {
defaultStreamType:
this.getAttribute(Attributes.DEFAULT_STREAM_TYPE) ?? undefined,
},
});
} else if (attrName === Attributes.LIVE_EDGE_OFFSET) {
this.#mediaStore?.dispatch({
type: 'optionschangerequest',
detail: {
liveEdgeOffset: this.hasAttribute(Attributes.LIVE_EDGE_OFFSET)
? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET)
: undefined,
},
});
} else if (attrName === Attributes.FULLSCREEN_ELEMENT) {
const el: HTMLElement = newValue
? (this.getRootNode() as Document)?.getElementById(newValue)
: undefined;
// NOTE: Setting the internal private prop here instead of using the setter to not
// clear the attribute that was just set (CJP).
this.#fullscreenElement = el;
// Use the getter in case the fullscreen element was reset to "`this`"
this.#mediaStore?.dispatch({
type: 'fullscreenelementchangerequest',
detail: this.fullscreenElement,
});
}
}
connectedCallback(): void {
// NOTE: Need to defer default MediaStore creation until connected for use cases that
// rely on createElement('media-controller') (like many frameworks "under the hood") (CJP).
if (!this.#mediaStore && !this.hasAttribute(Attributes.NO_DEFAULT_STORE)) {
this.#setupDefaultStore();
}
this.#mediaStore?.dispatch({
type: 'documentelementchangerequest',
detail: document,
});
// mediaSetCallback() is called in super.connectedCallback();
super.connectedCallback();
if (this.#mediaStore && !this.#mediaStoreUnsubscribe) {
this.#mediaStoreUnsubscribe = this.#mediaStore?.subscribe(
this.#mediaStateCallback
);
}
this.enableHotkeys();
}
disconnectedCallback(): void {
// mediaUnsetCallback() is called in super.disconnectedCallback();
super.disconnectedCallback?.();
if (this.#mediaStore) {
this.#mediaStore?.dispatch({
type: 'documentelementchangerequest',
detail: undefined,
});
/** @TODO Revisit: may not be necessary anymore or better solved via unsubscribe behavior? (CJP) */
// Disable captions on disconnect to prevent a memory leak if they stay enabled.
this.#mediaStore?.dispatch({
type: MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST,
detail: false,
});
}
if (this.#mediaStoreUnsubscribe) {
this.#mediaStoreUnsubscribe?.();
this.#mediaStoreUnsubscribe = undefined;
}
}
/**
* @override
* @param {HTMLMediaElement} media
*/
mediaSetCallback(media: HTMLMediaElement) {
super.mediaSetCallback(media);
this.#mediaStore?.dispatch({
type: 'mediaelementchangerequest',
detail: media,
});
// TODO: What does this do? At least add comment, maybe move to media-container
if (!media.hasAttribute('tabindex')) {
media.tabIndex = -1;
}
}
/**
* @override
* @param {HTMLMediaElement} media
*/
mediaUnsetCallback(media: HTMLMediaElement) {
super.mediaUnsetCallback(media);
this.#mediaStore?.dispatch({
type: 'mediaelementchangerequest',
detail: undefined,
});
}
propagateMediaState(stateName: string, state: any) {
propagateMediaState(this.mediaStateReceivers, stateName, state);
}
associateElement(element: HTMLElement) {
if (!element) return;
const { associatedElementSubscriptions } = this;
if (associatedElementSubscriptions.has(element)) return;
const registerMediaStateReceiver =
this.registerMediaStateReceiver.bind(this);
const unregisterMediaStateReceiver =
this.unregisterMediaStateReceiver.bind(this);
/** @TODO Should we support "removing association" */
const unsubscribe = monitorForMediaStateReceivers(
element,
registerMediaStateReceiver,
unregisterMediaStateReceiver
);
// Add all media request event listeners to the Associated Element. This allows any DOM element that
// is a descendant of any Associated Element (including the <media-controller/> itself) to make requests
// for media state changes rather than constraining that exclusively to a Media State Receivers.
// Still generically setup events -> mediaStore dispatch, since it will
// forward the events on to whichever store is defined (CJP)
Object.values(MediaUIEvents).forEach((eventName) => {
element.addEventListener(eventName, this.#mediaStateEventHandler);
});
associatedElementSubscriptions.set(element, unsubscribe);
}
unassociateElement(element: HTMLElement) {
if (!element) return;
const { associatedElementSubscriptions } = this;
if (!associatedElementSubscriptions.has(element)) return;
const unsubscribe = associatedElementSubscriptions.get(element);
unsubscribe();
associatedElementSubscriptions.delete(element);
// Remove all media UI event listeners
Object.values(MediaUIEvents).forEach((eventName) => {
element.removeEventListener(eventName, this.#mediaStateEventHandler);
});
}
registerMediaStateReceiver(el: HTMLElement) {
if (!el) return;
const els = this.mediaStateReceivers;
const index = els.indexOf(el);
if (index > -1) return;
els.push(el);
if (this.#mediaStore) {
Object.entries(this.#mediaStore.getState()).forEach(
([stateName, stateValue]) => {
propagateMediaState([el], stateName, stateValue);
}
);
}
}
unregisterMediaStateReceiver(el: HTMLElement) {
const els = this.mediaStateReceivers;
const index = els.indexOf(el);
if (index < 0) return;
els.splice(index, 1);
}
#keyUpHandler(e: KeyboardEvent) {
const { key } = e;
if (!ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
this.keyboardShortcutHandler(e);
}
#keyDownHandler(e: KeyboardEvent) {
const { metaKey, altKey, key } = e;
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
// if the pressed key might move the page, we need to preventDefault on keydown
// because doing so on keyup is too late
// We also want to make sure that the hotkey hasn't been turned off before doing so
if (
[' ', 'ArrowLeft', 'ArrowRight'].includes(key) &&
!(
this.#hotKeys.contains(`no${key.toLowerCase()}`) ||
(key === ' ' && this.#hotKeys.contains('nospace'))
)
) {
e.preventDefault();
}
this.addEventListener('keyup', this.#keyUpHandler, { once: true });
}
enableHotkeys() {
this.addEventListener('keydown', this.#keyDownHandler);
}
disableHotkeys() {
this.removeEventListener('keydown', this.#keyDownHandler);
this.removeEventListener('keyup', this.#keyUpHandler);
}
get hotkeys() {
return this.#hotKeys;
}
keyboardShortcutHandler(e: KeyboardEvent) {
// TODO: e.target might need to be replaced w/ e.composedPath to account for shadow DOM.
// if the event's key is already handled by the target, skip keyboard shortcuts
// keysUsed is either an attribute or a property.
// The attribute is a DOM array and the property is a JS array
// In the attribute Space represents the space key and gets convered to ' '
const target = e.target as any;
const keysUsed = (
target.getAttribute(Attributes.KEYS_USED)?.split(' ') ??
target?.keysUsed ??
[]
)
.map((key) => (key === 'Space' ? ' ' : key))
.filter(Boolean);
if (keysUsed.includes(e.key)) {
return;
}
let eventName, detail, evt;
// if the blocklist contains the key, skip handling it.
if (this.#hotKeys.contains(`no${e.key.toLowerCase()}`)) return;
if (e.key === ' ' && this.#hotKeys.contains(`nospace`)) return;
// These event triggers were copied from the revelant buttons
switch (e.key) {
case ' ':
case 'k':
eventName = this.#mediaStore.getState().mediaPaused
? MediaUIEvents.MEDIA_PLAY_REQUEST
: MediaUIEvents.MEDIA_PAUSE_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, {
composed: true,
bubbles: true,
})
);
break;
case 'm':
eventName =
this.mediaStore.getState().mediaVolumeLevel === 'off'
? MediaUIEvents.MEDIA_UNMUTE_REQUEST
: MediaUIEvents.MEDIA_MUTE_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, {
composed: true,
bubbles: true,
})
);
break;
case 'f':
eventName = this.mediaStore.getState().mediaIsFullscreen
? MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST
: MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, {
composed: true,
bubbles: true,
})
);
break;
case 'c':
this.dispatchEvent(
new globalThis.CustomEvent(
MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST,
{ composed: true, bubbles: true }
)
);
break;
case 'ArrowLeft': {
const offsetValue = this.hasAttribute(
Attributes.KEYBOARD_BACKWARD_SEEK_OFFSET
)
? +this.getAttribute(Attributes.KEYBOARD_BACKWARD_SEEK_OFFSET)
: DEFAULT_SEEK_OFFSET;
detail = Math.max(
(this.mediaStore.getState().mediaCurrentTime ?? 0) - offsetValue,
0
);
evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
break;
}
case 'ArrowRight': {
const offsetValue = this.hasAttribute(
Attributes.KEYBOARD_FORWARD_SEEK_OFFSET
)
? +this.getAttribute(Attributes.KEYBOARD_FORWARD_SEEK_OFFSET)
: DEFAULT_SEEK_OFFSET;
detail = Math.max(
(this.mediaStore.getState().mediaCurrentTime ?? 0) + offsetValue,
0
);
evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
break;
}
default:
break;
}
}
}
const MEDIA_UI_ATTRIBUTE_NAMES = Object.values(MediaUIAttributes);
const MEDIA_UI_PROP_NAMES = Object.values(MediaUIProps);
const getMediaUIAttributesFrom = (child: HTMLElement): string[] => {
let { observedAttributes } = child.constructor as typeof CustomElement;
// observedAttributes are only available if the custom element was upgraded.
// example: media-gesture-receiver in the shadow DOM requires an upgrade.
if (!observedAttributes && child.nodeName?.includes('-')) {
globalThis.customElements.upgrade(child);
({ observedAttributes } = child.constructor as typeof CustomElement);
}
const mediaChromeAttributesList = child
?.getAttribute?.(MediaStateReceiverAttributes.MEDIA_CHROME_ATTRIBUTES)
?.split?.(/\s+/);
if (!Array.isArray(observedAttributes || mediaChromeAttributesList))
return [];
return (observedAttributes || mediaChromeAttributesList).filter((attrName) =>
MEDIA_UI_ATTRIBUTE_NAMES.includes(attrName)
);
};
const hasMediaUIProps = (mediaStateReceiverCandidate: HTMLElement): boolean => {
if (
mediaStateReceiverCandidate.nodeName?.includes('-') &&
!!globalThis.customElements.get(
mediaStateReceiverCandidate.nodeName?.toLowerCase()
) &&
!(
mediaStateReceiverCandidate instanceof
globalThis.customElements.get(
mediaStateReceiverCandidate.nodeName.toLowerCase()
)
)
) {
globalThis.customElements.upgrade(mediaStateReceiverCandidate);
}
return MEDIA_UI_PROP_NAMES.some(
(propName) => propName in mediaStateReceiverCandidate
);
};
const isMediaStateReceiver = (child: HTMLElement): boolean => {
return hasMediaUIProps(child) || !!getMediaUIAttributesFrom(child).length;
};
const serializeTuple = (tuple: any[]): string | undefined => tuple?.join?.(':');
const CustomAttrSerializer: Record<string, (value: any) => string> = {
[MediaUIAttributes.MEDIA_SUBTITLES_LIST]: stringifyTextTrackList,
[MediaUIAttributes.MEDIA_SUBTITLES_SHOWING]: stringifyTextTrackList,
[MediaUIAttributes.MEDIA_SEEKABLE]: serializeTuple,
[MediaUIAttributes.MEDIA_BUFFERED]: (tuples: any[][]): string =>
tuples?.map(serializeTuple).join(' '),
[MediaUIAttributes.MEDIA_PREVIEW_COORDS]: (coords: number[]): string =>
coords?.join(' '),
[MediaUIAttributes.MEDIA_RENDITION_LIST]: stringifyRenditionList,
[MediaUIAttributes.MEDIA_AUDIO_TRACK_LIST]: stringifyAudioTrackList,
};
const setAttr = async (
child: HTMLElement,
attrName: string,
attrValue: any
): Promise<void> => {
// If the node is not connected to the DOM yet wait on macrotask. Fix for:
// Uncaught DOMException: Failed to construct 'CustomElement':
// The result must not have attributes
if (!child.isConnected) {
await delay(0);
}
// NOTE: For "nullish" (null/undefined), can use any setter
if (typeof attrValue === 'boolean' || attrValue == null) {
return setBooleanAttr(child, attrName, attrValue);
}
if (typeof attrValue === 'number') {
return setNumericAttr(child, attrName, attrValue);
}
if (typeof attrValue === 'string') {
return setStringAttr(child, attrName, attrValue);
}
// Treat empty arrays as "nothing" values
if (Array.isArray(attrValue) && !attrValue.length) {
return child.removeAttribute(attrName);
}
// For "special" values with custom serializers or all other values
const val = CustomAttrSerializer[attrName]?.(attrValue) ?? attrValue;
return child.setAttribute(attrName, val);
};
const isMediaSlotElementDescendant = (el: HTMLElement): boolean =>
!!el.closest?.('*[slot="media"]');
/**
*
* @description This function will recursively check for any descendants (including the rootNode)
* that are Media State Receivers and invoke `mediaStateReceiverCallback` with any Media State Receiver
* found
*
* @param {HTMLElement} rootNode
* @param {function} mediaStateReceiverCallback
*/
const traverseForMediaStateReceivers = (
rootNode: HTMLElement,
mediaStateReceiverCallback: (element: HTMLElement) => void
): void => {
// We (currently) don't check if descendants of the `media` (e.g. <video/>) are Media State Receivers
// See also: `propagateMediaState`
if (isMediaSlotElementDescendant(rootNode)) {
return;
}
const traverseForMediaStateReceiversSync = (
rootNode: HTMLElement,
mediaStateReceiverCallback: (element: HTMLElement) => void
): void => {
// The rootNode is itself a Media State Receiver
if (isMediaStateReceiver(rootNode)) {
mediaStateReceiverCallback(rootNode);
}
const { children = [] } = rootNode ?? {};
const shadowChildren = rootNode?.shadowRoot?.children ?? [];
const allChildren = [...children, ...shadowChildren];
// Traverse all children (including shadowRoot children) to see if they are/have Media State Receivers
allChildren.forEach((child) =>
traverseForMediaStateReceivers(
child as HTMLElement,
mediaStateReceiverCallback
)
);
};
// Custom Elements (and *only* Custom Elements) must have a hyphen ("-") in their name. So, if the rootNode is
// a custom element (aka has a hyphen in its name), wait until it's defined before attempting traversal to determine
// whether or not it or its descendants are Media State Receivers.
// IMPORTANT NOTE: We're intentionally *always* waiting for the `whenDefined()` Promise to resolve here
// (instead of using `globalThis.customElements.get(name)` to check if a custom element is already defined/registered)
// because we encountered some reliability issues with the custom element instances not being fully "ready", even if/when
// they are available in the registry via `globalThis.customElements.get(name)`.
const name = rootNode?.nodeName.toLowerCase();
if (name.includes('-') && !isMediaStateReceiver(rootNode)) {
globalThis.customElements.whenDefined(name).then(() => {
// Try/traverse again once the custom element is defined
traverseForMediaStateReceiversSync(rootNode, mediaStateReceiverCallback);
});
return;
}
traverseForMediaStateReceiversSync(rootNode, mediaStateReceiverCallback);
};
const propagateMediaState = (
els: HTMLElement[],
stateName: string,
val: any
): void => {
els.forEach((el) => {
if (stateName in el) {
el[stateName] = val;
return;
}
const relevantAttrs = getMediaUIAttributesFrom(el);
const attrName = stateName.toLowerCase();
if (!relevantAttrs.includes(attrName)) return;
setAttr(el, attrName, val);
});
};
/**
*
* @description This function will monitor the rootNode for any Media State Receiver descendants
* that are already present, added, or removed, invoking the relevant callback function for each
* case.
*
* @param {HTMLElement} rootNode
* @param {function} registerMediaStateReceiver
* @param {function} unregisterMediaStateReceiver
* @returns An unsubscribe method, used to stop monitoring descendants of rootNode and to unregister its descendants
*
*/
const monitorForMediaStateReceivers = (
rootNode: HTMLElement,
registerMediaStateReceiver: (el: HTMLElement) => void,
unregisterMediaStateReceiver: (el: HTMLElement) => void
): (() => void) => {
// First traverse the tree to register any current Media State Receivers
traverseForMediaStateReceivers(rootNode, registerMediaStateReceiver);
// Monitor for any event-based requests from descendants to register/unregister as a Media State Receiver
const registerMediaStateReceiverHandler = (evt: Event) => {
const el = evt?.composedPath()[0] ?? evt.target;
registerMediaStateReceiver(el as HTMLElement);
};
const unregisterMediaStateReceiverHandler = (evt: Event) => {
const el = evt?.composedPath()[0] ?? evt.target;
unregisterMediaStateReceiver(el as HTMLElement);
};
rootNode.addEventListener(
MediaUIEvents.REGISTER_MEDIA_STATE_RECEIVER,
registerMediaStateReceiverHandler
);
rootNode.addEventListener(
MediaUIEvents.UNREGISTER_MEDIA_STATE_RECEIVER,
unregisterMediaStateReceiverHandler
);
// Observe any changes to the DOM for any descendants that are identifiable as Media State Receivers
// and register or unregister them, depending on the change that occurred.
const mutationCallback = (mutationsList: MutationRecord[]) => {
mutationsList.forEach((mutationRecord) => {
const {
addedNodes = [],
removedNodes = [],
type,
target,
attributeName,
} = mutationRecord;
if (type === 'childList') {
// For each added node, register any Media State Receiver descendants (including itself)
Array.prototype.forEach.call(addedNodes, (node) =>
traverseForMediaStateReceivers(
node as HTMLElement,
registerMediaStateReceiver
)
);
// For each removed node, unregister any Media State Receiver descendants (including itself)
Array.prototype.forEach.call(removedNodes, (node) =>
traverseForMediaStateReceivers(
node as HTMLElement,
unregisterMediaStateReceiver
)
);
} else if (
type === 'attributes' &&
attributeName === MediaStateReceiverAttributes.MEDIA_CHROME_ATTRIBUTES
) {
if (isMediaStateReceiver(target as HTMLElement)) {
// Changed from a "non-Media State Receiver" to a Media State Receiver: register it.
registerMediaStateReceiver(target as HTMLElement);
} else {
// Changed from a Media State Receiver to a "non-Media State Receiver": unregister it.
unregisterMediaStateReceiver(target as HTMLElement);
}
}
});
};
// Storing prevSlotted elements so we can cleanup if slotted elements change over time.
let prevSlotted: HTMLElement[] = [];
const slotChangeHandler = (event: Event) => {
const slotEl = event.target as HTMLSlotElement;
if (slotEl.name === 'media') return;
prevSlotted.forEach((node) =>
traverseForMediaStateReceivers(node, unregisterMediaStateReceiver)
);
prevSlotted = [
...slotEl.assignedElements({ flatten: true }),
] as HTMLElement[];
prevSlotted.forEach((node) =>
traverseForMediaStateReceivers(node, registerMediaStateReceiver)
);
};
rootNode.addEventListener('slotchange', slotChangeHandler);
const observer = new MutationObserver(mutationCallback);
observer.observe(rootNode, {
childList: true,
attributes: true,
subtree: true,
});
const unsubscribe = () => {
// Unregister any Media State Receiver descendants (including ourselves)
traverseForMediaStateReceivers(rootNode, unregisterMediaStateReceiver);
// Make sure we remove the slotchange event listener
rootNode.removeEventListener('slotchange', slotChangeHandler);
// Stop observing for Media State Receivers
observer.disconnect();
// Stop listening for Media State Receiver events.
rootNode.removeEventListener(
MediaUIEvents.REGISTER_MEDIA_STATE_RECEIVER,
registerMediaStateReceiverHandler
);
rootNode.removeEventListener(
MediaUIEvents.UNREGISTER_MEDIA_STATE_RECEIVER,
unregisterMediaStateReceiverHandler
);
};
return unsubscribe;
};
if (!globalThis.customElements.get('media-controller')) {
globalThis.customElements.define('media-controller', MediaController);
}
export { MediaController };
export default MediaController;

View File

@@ -0,0 +1,57 @@
import { MediaTextDisplay } from './media-text-display.js';
import { globalThis } from './utils/server-safe-globals.js';
import { formatTime } from './utils/time.js';
import { MediaUIAttributes } from './constants.js';
import { getNumericAttr, setNumericAttr } from './utils/element-utils.js';
// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
/**
* @attr {string} mediaduration - (read-only) Set to the media duration.
*
* @cssproperty [--media-duration-display-display = inline-flex] - `display` property of display.
*/
class MediaDurationDisplay extends MediaTextDisplay {
/** @type {HTMLSlotElement} */
#slot: HTMLSlotElement;
static get observedAttributes(): string[] {
return [...super.observedAttributes, MediaUIAttributes.MEDIA_DURATION];
}
constructor() {
super();
this.#slot = this.shadowRoot.querySelector('slot') as HTMLSlotElement;
this.#slot.textContent = formatTime(0);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaUIAttributes.MEDIA_DURATION) {
this.#slot.textContent = formatTime(+newValue);
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
/**
* @type {number | undefined} In seconds
*/
get mediaDuration(): number | undefined {
return getNumericAttr(this, MediaUIAttributes.MEDIA_DURATION);
}
set mediaDuration(time: number | undefined) {
setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, time);
}
}
if (!globalThis.customElements.get('media-duration-display')) {
globalThis.customElements.define(
'media-duration-display',
MediaDurationDisplay
);
}
export default MediaDurationDisplay;

View File

@@ -0,0 +1,149 @@
/*
<media-fullscreen-button media="#myVideo" fullscreenelement="#myContainer">
The fullscreenelement attribute can be used to say which element
to make fullscreen.
If none, the button will look for the closest media-container element to the media.
If none, the button will make the media fullscreen.
*/
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import {
getBooleanAttr,
getStringAttr,
setBooleanAttr,
setStringAttr,
} from './utils/element-utils.js';
const enterFullscreenIcon = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M16 3v2.5h3.5V9H22V3h-6ZM4 9h2.5V5.5H10V3H4v6Zm15.5 9.5H16V21h6v-6h-2.5v3.5ZM6.5 15H4v6h6v-2.5H6.5V15Z"/>
</svg>`;
const exitFullscreenIcon = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M18.5 6.5V3H16v6h6V6.5h-3.5ZM16 21h2.5v-3.5H22V15h-6v6ZM4 17.5h3.5V21H10v-6H4v2.5Zm3.5-11H4V9h6V3H7.5v3.5Z"/>
</svg>`;
const slotTemplate = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([${
MediaUIAttributes.MEDIA_IS_FULLSCREEN
}]) slot[name=icon] slot:not([name=exit]) {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([${
MediaUIAttributes.MEDIA_IS_FULLSCREEN
}])) slot[name=icon] slot:not([name=enter]) {
display: none !important;
}
:host([${MediaUIAttributes.MEDIA_IS_FULLSCREEN}]) slot[name=tooltip-enter],
:host(:not([${
MediaUIAttributes.MEDIA_IS_FULLSCREEN
}])) slot[name=tooltip-exit] {
display: none;
}
</style>
<slot name="icon">
<slot name="enter">${enterFullscreenIcon}</slot>
<slot name="exit">${exitFullscreenIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-enter">${tooltipLabels.ENTER_FULLSCREEN}</slot>
<slot name="tooltip-exit">${tooltipLabels.EXIT_FULLSCREEN}</slot>
`;
const updateAriaLabel = (el: MediaFullscreenButton) => {
const label = el.mediaIsFullscreen
? verbs.EXIT_FULLSCREEN()
: verbs.ENTER_FULLSCREEN();
el.setAttribute('aria-label', label);
};
/**
* @slot enter - An element shown when the media is not in fullscreen and pressing the button will trigger entering fullscreen.
* @slot exit - An element shown when the media is in fullscreen and pressing the button will trigger exiting fullscreen.
* @slot icon - An element for representing enter and exit states in a single icon
*
* @attr {(unavailable|unsupported)} mediafullscreenunavailable - (read-only) Set if fullscreen is unavailable.
* @attr {boolean} mediaisfullscreen - (read-only) Present if the media is fullscreen.
*
* @cssproperty [--media-fullscreen-button-display = inline-flex] - `display` property of button.
*/
class MediaFullscreenButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_IS_FULLSCREEN,
MediaUIAttributes.MEDIA_FULLSCREEN_UNAVAILABLE,
];
}
constructor(options: object = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
super.connectedCallback();
updateAriaLabel(this);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_IS_FULLSCREEN) {
updateAriaLabel(this);
}
}
/**
* @type {string | undefined} Fullscreen unavailability state
*/
get mediaFullscreenUnavailable(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_FULLSCREEN_UNAVAILABLE);
}
set mediaFullscreenUnavailable(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_FULLSCREEN_UNAVAILABLE, value);
}
/**
* @type {boolean} Whether fullscreen is available
*/
get mediaIsFullscreen(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_IS_FULLSCREEN);
}
set mediaIsFullscreen(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_IS_FULLSCREEN, value);
}
handleClick(): void {
const eventName = this.mediaIsFullscreen
? MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST
: MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
if (!globalThis.customElements.get('media-fullscreen-button')) {
globalThis.customElements.define(
'media-fullscreen-button',
MediaFullscreenButton
);
}
export default MediaFullscreenButton;

View File

@@ -0,0 +1,211 @@
import {
MediaUIAttributes,
MediaUIEvents,
MediaStateReceiverAttributes,
PointerTypes,
} from './constants.js';
import {
closestComposedNode,
getBooleanAttr,
setBooleanAttr,
} from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
display: var(--media-control-display, var(--media-gesture-receiver-display, inline-block));
box-sizing: border-box;
}
</style>
`;
/**
* @extends {HTMLElement}
*
* @attr {boolean} mediapaused - (read-only) Present if the media is paused.
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
*
* @cssproperty --media-gesture-receiver-display - `display` property of gesture receiver.
* @cssproperty --media-control-display - `display` property of control.
*/
class MediaGestureReceiver extends globalThis.HTMLElement {
#mediaController;
// NOTE: Currently "baking in" actions + attrs until we come up with
// a more robust architecture (CJP)
static get observedAttributes(): string[] {
return [
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
MediaUIAttributes.MEDIA_PAUSED,
];
}
nativeEl: HTMLElement;
_pointerType: string;
constructor(
options: {
slotTemplate?: HTMLTemplateElement;
defaultContent?: string;
} = {}
) {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
const shadow = this.attachShadow({ mode: 'open' });
const buttonHTML = template.content.cloneNode(true);
this.nativeEl = buttonHTML as HTMLElement;
// Slots
let slotTemplate = options.slotTemplate;
if (!slotTemplate) {
slotTemplate = document.createElement('template');
slotTemplate.innerHTML = `<slot>${options.defaultContent || ''}</slot>`;
}
this.nativeEl.appendChild(slotTemplate.content.cloneNode(true));
shadow.appendChild(buttonHTML);
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
connectedCallback(): void {
this.tabIndex = -1;
this.setAttribute('aria-hidden', 'true');
this.#mediaController = getMediaControllerEl(this);
if (this.getAttribute(MediaStateReceiverAttributes.MEDIA_CONTROLLER)) {
this.#mediaController?.associateElement?.(this);
}
this.#mediaController?.addEventListener('pointerdown', this);
this.#mediaController?.addEventListener('click', this);
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
if (this.getAttribute(MediaStateReceiverAttributes.MEDIA_CONTROLLER)) {
this.#mediaController?.unassociateElement?.(this);
}
this.#mediaController?.removeEventListener('pointerdown', this);
this.#mediaController?.removeEventListener('click', this);
this.#mediaController = null;
}
handleEvent(event): void {
const composedTarget = event.composedPath()?.[0];
const allowList = ['video', 'media-controller'];
if (!allowList.includes(composedTarget?.localName)) return;
if (event.type === 'pointerdown') {
// Since not all browsers have updated to be spec compliant, where 'click' events should be PointerEvents,
// we can use use 'pointerdown' to reliably determine the pointer type. (CJP).
this._pointerType = event.pointerType;
} else if (event.type === 'click') {
// Cannot use composedPath or target because this is a layer on top and pointer events are disabled.
// Attach to window and check if click is in this element's bounding box to keep <video> right-click menu.
const { clientX, clientY } = event;
const { left, top, width, height } = this.getBoundingClientRect();
const x = clientX - left;
const y = clientY - top;
if (
x < 0 ||
y < 0 ||
x > width ||
y > height ||
// In case this element has no dimensions (or display: none) return.
(width === 0 && height === 0)
) {
return;
}
const { pointerType = this._pointerType } = event;
// NOTE: While there are cases where we may have a stale this._pointerType,
// we're guaranteed that the most recent this._pointerType will correspond
// to the current click event definitionally. As such, this clearing is technically
// unnecessary (CJP)
this._pointerType = undefined;
// NOTE: Longer term, we'll likely want to delay this to support double click/double tap (CJP)
if (pointerType === PointerTypes.TOUCH) {
this.handleTap(event);
return;
} else if (pointerType === PointerTypes.MOUSE) {
this.handleMouseClick(event);
return;
}
}
}
/**
* @type {boolean} Is the media paused
*/
get mediaPaused() {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED);
}
set mediaPaused(value) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value);
}
// NOTE: Currently "baking in" actions + attrs until we come up with
// a more robust architecture (CJP)
/**
* @abstract
* @argument {Event} e
*/
handleTap(e) {} // eslint-disable-line
// eslint-disable-next-line
handleMouseClick(e) {
const eventName = this.mediaPaused
? MediaUIEvents.MEDIA_PLAY_REQUEST
: MediaUIEvents.MEDIA_PAUSE_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
function getMediaControllerEl(controlEl) {
const mediaControllerId = controlEl.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
return controlEl.getRootNode()?.getElementById(mediaControllerId);
}
return closestComposedNode(controlEl, 'media-controller');
}
if (!globalThis.customElements.get('media-gesture-receiver')) {
globalThis.customElements.define(
'media-gesture-receiver',
MediaGestureReceiver
);
}
export default MediaGestureReceiver;

View File

@@ -0,0 +1,142 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { verbs } from './labels/labels.js';
import { getBooleanAttr, setBooleanAttr } from './utils/element-utils.js';
const { MEDIA_TIME_IS_LIVE, MEDIA_PAUSED } = MediaUIAttributes;
const { MEDIA_SEEK_TO_LIVE_REQUEST, MEDIA_PLAY_REQUEST } = MediaUIEvents;
const indicatorSVG =
'<svg viewBox="0 0 6 12"><circle cx="3" cy="6" r="2"></circle></svg>';
const slotTemplate = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host { --media-tooltip-display: none; }
slot[name=indicator] > *,
:host ::slotted([slot=indicator]) {
${/* Override styles for icon-only buttons */ ''}
min-width: auto;
fill: var(--media-live-button-icon-color, rgb(140, 140, 140));
color: var(--media-live-button-icon-color, rgb(140, 140, 140));
}
:host([${MEDIA_TIME_IS_LIVE}]:not([${MEDIA_PAUSED}])) slot[name=indicator] > *,
:host([${MEDIA_TIME_IS_LIVE}]:not([${MEDIA_PAUSED}])) ::slotted([slot=indicator]) {
fill: var(--media-live-button-indicator-color, rgb(255, 0, 0));
color: var(--media-live-button-indicator-color, rgb(255, 0, 0));
}
:host([${MEDIA_TIME_IS_LIVE}]:not([${MEDIA_PAUSED}])) {
cursor: not-allowed;
}
</style>
<slot name="indicator">${indicatorSVG}</slot>
${
/*
A new line between spacer and text creates inconsistent spacing
between slotted items and default slots.
*/ ''
}
<slot name="spacer">&nbsp;</slot><slot name="text">LIVE</slot>
`;
const updateAriaAttributes = (el: MediaLiveButton): void => {
const isPausedOrNotLive = el.mediaPaused || !el.mediaTimeIsLive;
const label = isPausedOrNotLive ? verbs.SEEK_LIVE() : verbs.PLAYING_LIVE();
el.setAttribute('aria-label', label);
isPausedOrNotLive
? el.removeAttribute('aria-disabled')
: el.setAttribute('aria-disabled', 'true');
};
/**
* @slot indicator - The default is an SVG of a circle that changes to red when the video or audio is live. Can be replaced with your own SVG or font icon.
* @slot spacer - A simple text space (&nbsp;) between the indicator and the text.
* @slot text - The text content of the button, with a default of “LIVE”.
*
* @attr {boolean} mediapaused - (read-only) Present if the media is paused.
* @attr {boolean} mediatimeislive - (read-only) Present if the media playback is live.
*
* @cssproperty [--media-live-button-display = inline-flex] - `display` property of button.
* @cssproperty --media-live-button-icon-color - `fill` and `color` of not live button icon.
* @cssproperty --media-live-button-indicator-color - `fill` and `color` of live button icon.
*/
class MediaLiveButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [...super.observedAttributes, MEDIA_PAUSED, MEDIA_TIME_IS_LIVE];
}
constructor(options: object = {}) {
super({ slotTemplate, ...options });
}
connectedCallback(): void {
updateAriaAttributes(this);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
updateAriaAttributes(this);
}
/**
* @type {boolean} Is the media paused
*/
get mediaPaused(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED);
}
set mediaPaused(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value);
}
/**
* @type {boolean} Is the media playback currently live
*/
get mediaTimeIsLive(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_TIME_IS_LIVE);
}
set mediaTimeIsLive(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_TIME_IS_LIVE, value);
}
handleClick(): void {
// If we're live and not paused, don't allow seek to live
if (!this.mediaPaused && this.mediaTimeIsLive) return;
this.dispatchEvent(
new globalThis.CustomEvent(MEDIA_SEEK_TO_LIVE_REQUEST, {
composed: true,
bubbles: true,
})
);
// If we're paused, also automatically play
if (this.hasAttribute(MEDIA_PAUSED)) {
this.dispatchEvent(
new globalThis.CustomEvent(MEDIA_PLAY_REQUEST, {
composed: true,
bubbles: true,
})
);
}
}
}
if (!globalThis.customElements.get('media-live-button')) {
globalThis.customElements.define('media-live-button', MediaLiveButton);
}
export default MediaLiveButton;

View File

@@ -0,0 +1,219 @@
import {
MediaUIAttributes,
MediaStateReceiverAttributes,
} from './constants.js';
import { nouns } from './labels/labels.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import {
getBooleanAttr,
setBooleanAttr,
getOrInsertCSSRule,
} from './utils/element-utils.js';
import MediaController from './media-controller.js';
export const Attributes = {
LOADING_DELAY: 'loadingdelay',
};
const DEFAULT_LOADING_DELAY = 500;
const template: HTMLTemplateElement = document.createElement('template');
const loadingIndicatorIcon = `
<svg aria-hidden="true" viewBox="0 0 100 100">
<path d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="1s"
from="0 50 50"
to="360 50 50"
repeatCount="indefinite" />
</path>
</svg>
`;
template.innerHTML = /*html*/ `
<style>
:host {
display: var(--media-control-display, var(--media-loading-indicator-display, inline-block));
vertical-align: middle;
box-sizing: border-box;
--_loading-indicator-delay: var(--media-loading-indicator-transition-delay, ${DEFAULT_LOADING_DELAY}ms);
}
#status {
color: rgba(0,0,0,0);
width: 0px;
height: 0px;
}
:host slot[name=icon] > *,
:host ::slotted([slot=icon]) {
opacity: var(--media-loading-indicator-opacity, 0);
transition: opacity 0.15s;
}
:host([${MediaUIAttributes.MEDIA_LOADING}]:not([${
MediaUIAttributes.MEDIA_PAUSED
}])) slot[name=icon] > *,
:host([${MediaUIAttributes.MEDIA_LOADING}]:not([${
MediaUIAttributes.MEDIA_PAUSED
}])) ::slotted([slot=icon]) {
opacity: var(--media-loading-indicator-opacity, 1);
transition: opacity 0.15s var(--_loading-indicator-delay);
}
:host #status {
visibility: var(--media-loading-indicator-opacity, hidden);
transition: visibility 0.15s;
}
:host([${MediaUIAttributes.MEDIA_LOADING}]:not([${
MediaUIAttributes.MEDIA_PAUSED
}])) #status {
visibility: var(--media-loading-indicator-opacity, visible);
transition: visibility 0.15s var(--_loading-indicator-delay);
}
svg, img, ::slotted(svg), ::slotted(img) {
width: var(--media-loading-indicator-icon-width);
height: var(--media-loading-indicator-icon-height, 100px);
fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)));
vertical-align: middle;
}
</style>
<slot name="icon">${loadingIndicatorIcon}</slot>
<div id="status" role="status" aria-live="polite">${nouns.MEDIA_LOADING()}</div>
`;
/**
* @slot icon - The element shown for when the media is in a buffering state.
*
* @attr {string} loadingdelay - Set the delay in ms before the loading animation is shown.
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
* @attr {boolean} mediapaused - (read-only) Present if the media is paused.
* @attr {boolean} medialoading - (read-only) Present if the media is loading.
*
* @cssproperty --media-primary-color - Default color of text and icon.
* @cssproperty --media-icon-color - `fill` color of button icon.
*
* @cssproperty --media-control-display - `display` property of control.
*
* @cssproperty --media-loading-indicator-display - `display` property of loading indicator.
* @cssproperty [ --media-loading-indicator-opacity = 0 ] - `opacity` property of loading indicator. Set to 1 to force it to be visible.
* @cssproperty [ --media-loading-indicator-transition-delay = 500ms ] - `transition-delay` property of loading indicator. Make sure to include units.
* @cssproperty --media-loading-indicator-icon-width - `width` of loading icon.
* @cssproperty [ --media-loading-indicator-icon-height = 100px ] - `height` of loading icon.
*/
class MediaLoadingIndicator extends globalThis.HTMLElement {
#mediaController: MediaController;
#delay = DEFAULT_LOADING_DELAY;
static get observedAttributes(): string[] {
return [
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
MediaUIAttributes.MEDIA_PAUSED,
MediaUIAttributes.MEDIA_LOADING,
Attributes.LOADING_DELAY,
];
}
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
const shadow = this.attachShadow({ mode: 'open' });
const indicatorHTML = template.content.cloneNode(true);
shadow.appendChild(indicatorHTML);
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.LOADING_DELAY && oldValue !== newValue) {
this.loadingDelay = Number(newValue);
} else if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
connectedCallback(): void {
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
// @ts-ignore
this.#mediaController = (this.getRootNode() as Document)?.getElementById(
mediaControllerId
);
this.#mediaController?.associateElement?.(this);
}
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
/**
* Delay in ms
*/
get loadingDelay(): number {
return this.#delay;
}
set loadingDelay(delay: number) {
this.#delay = delay;
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.setProperty(
'--_loading-indicator-delay',
`var(--media-loading-indicator-transition-delay, ${delay}ms)`
);
}
/**
* Is the media paused
*/
get mediaPaused(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED);
}
set mediaPaused(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value);
}
/**
* Is the media loading
*/
get mediaLoading(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING);
}
set mediaLoading(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING, value);
}
}
if (!globalThis.customElements.get('media-loading-indicator')) {
globalThis.customElements.define(
'media-loading-indicator',
MediaLoadingIndicator
);
}
export default MediaLoadingIndicator;

View File

@@ -0,0 +1,129 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import { getStringAttr, setStringAttr } from './utils/element-utils.js';
const { MEDIA_VOLUME_LEVEL } = MediaUIAttributes;
const offIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M16.5 12A4.5 4.5 0 0 0 14 8v2.18l2.45 2.45a4.22 4.22 0 0 0 .05-.63Zm2.5 0a6.84 6.84 0 0 1-.54 2.64L20 16.15A8.8 8.8 0 0 0 21 12a9 9 0 0 0-7-8.77v2.06A7 7 0 0 1 19 12ZM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25A6.92 6.92 0 0 1 14 18.7v2.06A9 9 0 0 0 17.69 19l2 2.05L21 19.73l-9-9L4.27 3ZM12 4 9.91 6.09 12 8.18V4Z"/>
</svg>`;
const lowIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4Z"/>
</svg>`;
const highIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4ZM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54Z"/>
</svg>`;
const slotTemplate = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
${/* Default to High slot/icon. */ ''}
:host(:not([${MEDIA_VOLUME_LEVEL}])) slot[name=icon] slot:not([name=high]),
:host([${MEDIA_VOLUME_LEVEL}=high]) slot[name=icon] slot:not([name=high]) {
display: none !important;
}
:host([${MEDIA_VOLUME_LEVEL}=off]) slot[name=icon] slot:not([name=off]) {
display: none !important;
}
:host([${MEDIA_VOLUME_LEVEL}=low]) slot[name=icon] slot:not([name=low]) {
display: none !important;
}
:host([${MEDIA_VOLUME_LEVEL}=medium]) slot[name=icon] slot:not([name=medium]) {
display: none !important;
}
:host(:not([${MEDIA_VOLUME_LEVEL}=off])) slot[name=tooltip-unmute],
:host([${MEDIA_VOLUME_LEVEL}=off]) slot[name=tooltip-mute] {
display: none;
}
</style>
<slot name="icon">
<slot name="off">${offIcon}</slot>
<slot name="low">${lowIcon}</slot>
<slot name="medium">${lowIcon}</slot>
<slot name="high">${highIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-mute">${tooltipLabels.MUTE}</slot>
<slot name="tooltip-unmute">${tooltipLabels.UNMUTE}</slot>
`;
const updateAriaLabel = (el: MediaMuteButton) => {
const muted = el.mediaVolumeLevel === 'off';
const label = muted ? verbs.UNMUTE() : verbs.MUTE();
el.setAttribute('aria-label', label);
};
/**
* @slot off - An element shown when the media is muted or the medias volume is 0.
* @slot low - An element shown when the medias volume is “low” (less than 50% / 0.5).
* @slot medium - An element shown when the medias volume is “medium” (between 50% / 0.5 and 75% / 0.75).
* @slot high - An element shown when the medias volume is “high” (75% / 0.75 or greater).
* @slot icon - An element for representing all states in a single icon
*
* @attr {string} mediavolumelevel - (read-only) Set to the media volume level.
*
* @cssproperty [--media-mute-button-display = inline-flex] - `display` property of button.
*/
class MediaMuteButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [...super.observedAttributes, MediaUIAttributes.MEDIA_VOLUME_LEVEL];
}
constructor(options: object = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
updateAriaLabel(this);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaUIAttributes.MEDIA_VOLUME_LEVEL) {
updateAriaLabel(this);
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
/**
* @type {string | undefined}
*/
get mediaVolumeLevel(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_VOLUME_LEVEL);
}
set mediaVolumeLevel(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_VOLUME_LEVEL, value);
}
handleClick(): void {
const eventName: string =
this.mediaVolumeLevel === 'off'
? MediaUIEvents.MEDIA_UNMUTE_REQUEST
: MediaUIEvents.MEDIA_MUTE_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
if (!globalThis.customElements.get('media-mute-button')) {
globalThis.customElements.define('media-mute-button', MediaMuteButton);
}
export default MediaMuteButton;

View File

@@ -0,0 +1,129 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import {
getBooleanAttr,
getStringAttr,
setBooleanAttr,
setStringAttr,
} from './utils/element-utils.js';
const pipIcon = `<svg aria-hidden="true" viewBox="0 0 28 24">
<path d="M24 3H4a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-1 16H5V5h18v14Zm-3-8h-7v5h7v-5Z"/>
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([${
MediaUIAttributes.MEDIA_IS_PIP
}]) slot[name=icon] slot:not([name=exit]) {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([${
MediaUIAttributes.MEDIA_IS_PIP
}])) slot[name=icon] slot:not([name=enter]) {
display: none !important;
}
:host([${MediaUIAttributes.MEDIA_IS_PIP}]) slot[name=tooltip-enter],
:host(:not([${MediaUIAttributes.MEDIA_IS_PIP}])) slot[name=tooltip-exit] {
display: none;
}
</style>
<slot name="icon">
<slot name="enter">${pipIcon}</slot>
<slot name="exit">${pipIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-enter">${tooltipLabels.ENTER_PIP}</slot>
<slot name="tooltip-exit">${tooltipLabels.EXIT_PIP}</slot>
`;
const updateAriaLabel = (el: MediaPipButton): void => {
const label = el.mediaIsPip ? verbs.EXIT_PIP() : verbs.ENTER_PIP();
el.setAttribute('aria-label', label);
};
/**
* @slot enter - An element shown when the media is not in PIP mode and pressing the button will trigger entering PIP mode.
* @slot exit - An element shown when the media is in PIP and pressing the button will trigger exiting PIP mode.
* @slot icon - An element for representing enter and exit states in a single icon
*
* @attr {(unavailable|unsupported)} mediapipunavailable - (read-only) Set if picture-in-picture is unavailable.
* @attr {boolean} mediaispip - (read-only) Present if the media is playing in picture-in-picture.
*
* @cssproperty [--media-pip-button-display = inline-flex] - `display` property of button.
*/
class MediaPipButton extends MediaChromeButton {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_IS_PIP,
MediaUIAttributes.MEDIA_PIP_UNAVAILABLE,
];
}
constructor(options: object = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
updateAriaLabel(this);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaUIAttributes.MEDIA_IS_PIP) {
updateAriaLabel(this);
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
/**
* @type {string | undefined} Pip unavailability state
*/
get mediaPipUnavailable(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_PIP_UNAVAILABLE);
}
set mediaPipUnavailable(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_PIP_UNAVAILABLE, value);
}
/**
* @type {boolean} Is the media currently playing picture-in-picture
*/
get mediaIsPip(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_IS_PIP);
}
set mediaIsPip(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_IS_PIP, value);
}
handleClick(): void {
const eventName = this.mediaIsPip
? MediaUIEvents.MEDIA_EXIT_PIP_REQUEST
: MediaUIEvents.MEDIA_ENTER_PIP_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
if (!globalThis.customElements.get('media-pip-button')) {
globalThis.customElements.define('media-pip-button', MediaPipButton);
}
export default MediaPipButton;

View File

@@ -0,0 +1,109 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import { getBooleanAttr, setBooleanAttr } from './utils/element-utils.js';
const playIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 21 15-9L6 3v18Z"/>
</svg>`;
const pauseIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M6 20h4V4H6v16Zm8-16v16h4V4h-4Z"/>
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([${MediaUIAttributes.MEDIA_PAUSED}]) slot[name=pause],
:host(:not([${MediaUIAttributes.MEDIA_PAUSED}])) slot[name=play] {
display: none !important;
}
:host([${MediaUIAttributes.MEDIA_PAUSED}]) slot[name=tooltip-pause],
:host(:not([${MediaUIAttributes.MEDIA_PAUSED}])) slot[name=tooltip-play] {
display: none;
}
</style>
<slot name="icon">
<slot name="play">${playIcon}</slot>
<slot name="pause">${pauseIcon}</slot>
</slot>
`;
const tooltipContent = /*html*/ `
<slot name="tooltip-play">${tooltipLabels.PLAY}</slot>
<slot name="tooltip-pause">${tooltipLabels.PAUSE}</slot>
`;
const updateAriaLabel = (el: any): void => {
const label = el.mediaPaused ? verbs.PLAY() : verbs.PAUSE();
el.setAttribute('aria-label', label);
};
/**
* @slot play - An element shown when the media is paused and pressing the button will start media playback.
* @slot pause - An element shown when the media is playing and pressing the button will pause media playback.
* @slot icon - An element for representing play and pause states in a single icon
*
* @attr {boolean} mediapaused - (read-only) Present if the media is paused.
*
* @cssproperty [--media-play-button-display = inline-flex] - `display` property of button.
*/
class MediaPlayButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PAUSED,
MediaUIAttributes.MEDIA_ENDED,
];
}
constructor(options = {}) {
super({ slotTemplate, tooltipContent, ...options });
}
connectedCallback(): void {
updateAriaLabel(this);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaUIAttributes.MEDIA_PAUSED) {
updateAriaLabel(this);
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
/**
* Is the media paused
*/
get mediaPaused(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED);
}
set mediaPaused(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value);
}
handleClick(): void {
const eventName = this.mediaPaused
? MediaUIEvents.MEDIA_PLAY_REQUEST
: MediaUIEvents.MEDIA_PAUSE_REQUEST;
this.dispatchEvent(
new globalThis.CustomEvent(eventName, { composed: true, bubbles: true })
);
}
}
if (!globalThis.customElements.get('media-play-button')) {
globalThis.customElements.define('media-play-button', MediaPlayButton);
}
export { MediaPlayButton };
export default MediaPlayButton;

View File

@@ -0,0 +1,132 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { nouns, tooltipLabels } from './labels/labels.js';
import { AttributeTokenList } from './utils/attribute-token-list.js';
import { getNumericAttr, setNumericAttr } from './utils/element-utils.js';
export const Attributes = {
RATES: 'rates',
};
export const DEFAULT_RATES = [1, 1.2, 1.5, 1.7, 2];
export const DEFAULT_RATE = 1;
const slotTemplate = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host {
min-width: 5ch;
padding: var(--media-button-padding, var(--media-control-padding, 10px 5px));
}
</style>
<slot name="icon"></slot>
`;
/**
* @attr {string} rates - Set custom playback rates for the user to choose from.
* @attr {string} mediaplaybackrate - (read-only) Set to the media playback rate.
*
* @cssproperty [--media-playback-rate-button-display = inline-flex] - `display` property of button.
*/
class MediaPlaybackRateButton extends MediaChromeButton {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
Attributes.RATES,
];
}
#rates = new AttributeTokenList(this, Attributes.RATES, {
defaultValue: DEFAULT_RATES,
});
container: HTMLSlotElement;
constructor(options = {}) {
super({
slotTemplate,
tooltipContent: tooltipLabels.PLAYBACK_RATE,
...options,
});
this.container = this.shadowRoot.querySelector('slot[name="icon"]');
this.container.innerHTML = `${DEFAULT_RATE}x`;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === Attributes.RATES) {
this.#rates.value = newValue;
}
if (attrName === MediaUIAttributes.MEDIA_PLAYBACK_RATE) {
const newPlaybackRate = newValue ? +newValue : Number.NaN;
const playbackRate = !Number.isNaN(newPlaybackRate)
? newPlaybackRate
: DEFAULT_RATE;
this.container.innerHTML = `${playbackRate}x`;
this.setAttribute('aria-label', nouns.PLAYBACK_RATE({ playbackRate }));
}
}
/**
* @type { AttributeTokenList | Array<number> | undefined} Will return a DOMTokenList.
* Setting a value will accept an array of numbers.
*/
get rates() {
return this.#rates;
}
set rates(value) {
if (!value) {
this.#rates.value = '';
} else if (Array.isArray(value)) {
this.#rates.value = value.join(' ');
}
}
/**
* @type {number} The current playback rate
*/
get mediaPlaybackRate() {
return getNumericAttr(
this,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
DEFAULT_RATE
);
}
set mediaPlaybackRate(value) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, value);
}
handleClick() {
const availableRates = Array.from(this.rates.values(), (str) => +str).sort(
(a, b) => a - b
);
const detail =
availableRates.find((r) => r > this.mediaPlaybackRate) ??
availableRates[0] ??
DEFAULT_RATE;
const evt = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_PLAYBACK_RATE_REQUEST,
{ composed: true, bubbles: true, detail }
);
this.dispatchEvent(evt);
}
}
if (!globalThis.customElements.get('media-playback-rate-button')) {
globalThis.customElements.define(
'media-playback-rate-button',
MediaPlaybackRateButton
);
}
export default MediaPlaybackRateButton;

View File

@@ -0,0 +1,120 @@
import { globalThis, document } from './utils/server-safe-globals.js';
import { getStringAttr, setStringAttr } from './utils/element-utils.js';
export const Attributes = {
PLACEHOLDER_SRC: 'placeholdersrc',
SRC: 'src',
};
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
pointer-events: none;
display: var(--media-poster-image-display, inline-block);
box-sizing: border-box;
}
img {
max-width: 100%;
max-height: 100%;
min-width: 100%;
min-height: 100%;
background-repeat: no-repeat;
background-position: var(--media-poster-image-background-position, var(--media-object-position, center));
background-size: var(--media-poster-image-background-size, var(--media-object-fit, contain));
object-fit: var(--media-object-fit, contain);
object-position: var(--media-object-position, center);
}
</style>
<img part="poster img" aria-hidden="true" id="image"/>
`;
const unsetBackgroundImage = (el: HTMLElement): void => {
el.style.removeProperty('background-image');
};
const setBackgroundImage = (el: HTMLElement, image: string): void => {
el.style['background-image'] = `url('${image}')`;
};
/**
* @attr {string} placeholdersrc - Placeholder image source URL, often a blurhash data URL.
* @attr {string} src - Poster image source URL.
*
* @cssproperty --media-poster-image-display - `display` property of poster image.
* @cssproperty --media-poster-image-background-position - `background-position` of poster image.
* @cssproperty --media-poster-image-background-size - `background-size` of poster image.
* @cssproperty --media-object-fit - `object-fit` of poster image.
* @cssproperty --media-object-position - `object-position` of poster image.
*/
class MediaPosterImage extends globalThis.HTMLElement {
static get observedAttributes(): string[] {
return [Attributes.PLACEHOLDER_SRC, Attributes.SRC];
}
image: HTMLImageElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
this.image = this.shadowRoot.querySelector('#image');
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.SRC) {
if (newValue == null) {
this.image.removeAttribute(Attributes.SRC);
} else {
this.image.setAttribute(Attributes.SRC, newValue);
}
}
if (attrName === Attributes.PLACEHOLDER_SRC) {
if (newValue == null) {
unsetBackgroundImage(this.image);
} else {
setBackgroundImage(this.image, newValue);
}
}
}
/**
*
*/
get placeholderSrc(): string | undefined {
return getStringAttr(this, Attributes.PLACEHOLDER_SRC);
}
set placeholderSrc(value: string | undefined) {
setStringAttr(this, Attributes.SRC, value);
}
/**
*
*/
get src(): string | undefined {
return getStringAttr(this, Attributes.SRC);
}
set src(value: string | undefined) {
setStringAttr(this, Attributes.SRC, value);
}
}
if (!globalThis.customElements.get('media-poster-image')) {
globalThis.customElements.define('media-poster-image', MediaPosterImage);
}
export default MediaPosterImage;

View File

@@ -0,0 +1,66 @@
import { MediaTextDisplay } from './media-text-display.js';
import { globalThis } from './utils/server-safe-globals.js';
import { MediaUIAttributes } from './constants.js';
import { getStringAttr, setStringAttr } from './utils/element-utils.js';
/**
* @attr {string} mediapreviewchapter - (read-only) Set to the timeline preview chapter.
*
* @cssproperty [--media-preview-chapter-display-display = inline-flex] - `display` property of display.
*/
class MediaPreviewChapterDisplay extends MediaTextDisplay {
#slot: HTMLSlotElement;
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PREVIEW_CHAPTER,
];
}
constructor() {
super();
this.#slot = this.shadowRoot.querySelector('slot') as HTMLSlotElement;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_PREVIEW_CHAPTER) {
// Only update if it changed, preview events are called a few times per second.
if (newValue !== oldValue && newValue != null) {
this.#slot.textContent = newValue;
if (newValue !== '') {
this.setAttribute('aria-valuetext', `chapter: ${newValue}`);
} else {
this.removeAttribute('aria-valuetext');
}
}
}
}
/**
* @type {string | undefined} Timeline preview chapter
*/
get mediaPreviewChapter(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_CHAPTER);
}
set mediaPreviewChapter(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_CHAPTER, value);
}
}
if (!globalThis.customElements.get('media-preview-chapter-display')) {
globalThis.customElements.define(
'media-preview-chapter-display',
MediaPreviewChapterDisplay
);
}
export default MediaPreviewChapterDisplay;

View File

@@ -0,0 +1,195 @@
import { globalThis, document } from './utils/server-safe-globals.js';
import {
MediaUIAttributes,
MediaStateReceiverAttributes,
} from './constants.js';
import {
getOrInsertCSSRule,
getStringAttr,
setStringAttr,
} from './utils/element-utils.js';
import MediaController from './media-controller.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
box-sizing: border-box;
display: var(--media-control-display, var(--media-preview-thumbnail-display, inline-block));
overflow: hidden;
}
img {
display: none;
position: relative;
}
</style>
<img crossorigin loading="eager" decoding="async">
`;
/**
*
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
* @attr {string} mediapreviewimage - (read-only) Set to the timeline preview image URL.
* @attr {string} mediapreviewcoords - (read-only) Set to the active preview image coordinates.
*
* @cssproperty [--media-preview-thumbnail-display = inline-block] - `display` property of display.
* @cssproperty [--media-control-display = inline-block] - `display` property of control.
*/
class MediaPreviewThumbnail extends globalThis.HTMLElement {
#mediaController: MediaController;
static get observedAttributes() {
return [
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
MediaUIAttributes.MEDIA_PREVIEW_IMAGE,
MediaUIAttributes.MEDIA_PREVIEW_COORDS,
];
}
imgWidth: number;
imgHeight: number;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
connectedCallback(): void {
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
this.#mediaController =
// @ts-ignore
this.getRootNode()?.getElementById(mediaControllerId);
this.#mediaController?.associateElement?.(this);
}
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (
[
MediaUIAttributes.MEDIA_PREVIEW_IMAGE,
MediaUIAttributes.MEDIA_PREVIEW_COORDS,
].includes(attrName as any)
) {
this.update();
}
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
/**
* @type {string | undefined} The url of the preview image
*/
get mediaPreviewImage() {
return getStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE);
}
set mediaPreviewImage(value) {
setStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE, value);
}
/**
* @type {Array<number> | undefined} Fixed length array [x, y, width, height] or undefined
*/
get mediaPreviewCoords() {
const attrVal = this.getAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS);
if (!attrVal) return undefined;
return attrVal.split(/\s+/).map((coord) => +coord);
}
set mediaPreviewCoords(value) {
if (!value) {
this.removeAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS);
return;
}
this.setAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS, value.join(' '));
}
update(): void {
const coords = this.mediaPreviewCoords;
const previewImage = this.mediaPreviewImage;
if (!(coords && previewImage)) return;
const [x, y, w, h] = coords;
const src = previewImage.split('#')[0];
const computedStyle = getComputedStyle(this);
const { maxWidth, maxHeight, minWidth, minHeight } = computedStyle;
const maxRatio = Math.min(parseInt(maxWidth) / w, parseInt(maxHeight) / h);
const minRatio = Math.max(parseInt(minWidth) / w, parseInt(minHeight) / h);
// maxRatio scales down and takes priority, minRatio scales up.
const isScalingDown = maxRatio < 1;
const scale = isScalingDown ? maxRatio : minRatio > 1 ? minRatio : 1;
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
const imgStyle = getOrInsertCSSRule(this.shadowRoot, 'img').style;
const img = this.shadowRoot.querySelector('img');
// Revert one set of extremum to its initial value on a known scale direction.
const extremum = isScalingDown ? 'min' : 'max';
style.setProperty(`${extremum}-width`, 'initial', 'important');
style.setProperty(`${extremum}-height`, 'initial', 'important');
style.width = `${w * scale}px`;
style.height = `${h * scale}px`;
const resize = () => {
imgStyle.width = `${this.imgWidth * scale}px`;
imgStyle.height = `${this.imgHeight * scale}px`;
imgStyle.display = 'block';
};
if (img.src !== src) {
img.onload = () => {
this.imgWidth = img.naturalWidth;
this.imgHeight = img.naturalHeight;
resize();
};
img.src = src;
resize();
}
resize();
imgStyle.transform = `translate(-${x * scale}px, -${y * scale}px)`;
}
}
if (!globalThis.customElements.get('media-preview-thumbnail')) {
globalThis.customElements.define(
'media-preview-thumbnail',
MediaPreviewThumbnail
);
}
export default MediaPreviewThumbnail;

View File

@@ -0,0 +1,57 @@
import { MediaTextDisplay } from './media-text-display.js';
import { globalThis } from './utils/server-safe-globals.js';
import { formatTime } from './utils/time.js';
import { MediaUIAttributes } from './constants.js';
import { getNumericAttr, setNumericAttr } from './utils/element-utils.js';
// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
/**
* @attr {string} mediapreviewtime - (read-only) Set to the timeline preview time.
*
* @cssproperty [--media-preview-time-display-display = inline-flex] - `display` property of display.
*/
class MediaPreviewTimeDisplay extends MediaTextDisplay {
#slot: HTMLSlotElement;
static get observedAttributes() {
return [...super.observedAttributes, MediaUIAttributes.MEDIA_PREVIEW_TIME];
}
constructor() {
super();
this.#slot = this.shadowRoot.querySelector('slot');
this.#slot.textContent = formatTime(0);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_PREVIEW_TIME && newValue != null) {
this.#slot.textContent = formatTime(parseFloat(newValue));
}
}
/**
* Timeline preview time
*/
get mediaPreviewTime(): number | undefined {
return getNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME);
}
set mediaPreviewTime(value: number | undefined) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME, value);
}
}
if (!globalThis.customElements.get('media-preview-time-display')) {
globalThis.customElements.define(
'media-preview-time-display',
MediaPreviewTimeDisplay
);
}
export default MediaPreviewTimeDisplay;

View File

@@ -0,0 +1,126 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { getNumericAttr, setNumericAttr } from './utils/element-utils.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import { getSlotted, updateIconText } from './utils/element-utils.js';
export const Attributes = {
SEEK_OFFSET: 'seekoffset',
};
const DEFAULT_SEEK_OFFSET = 30;
const backwardIcon = `<svg aria-hidden="true" viewBox="0 0 20 24"><defs><style>.text{font-size:8px;font-family:Arial-BoldMT, Arial;font-weight:700;}</style></defs><text class="text value" transform="translate(2.18 19.87)">${DEFAULT_SEEK_OFFSET}</text><path d="M10 6V3L4.37 7 10 10.94V8a5.54 5.54 0 0 1 1.9 10.48v2.12A7.5 7.5 0 0 0 10 6Z"/></svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = `
<slot name="icon">${backwardIcon}</slot>
`;
const DEFAULT_TIME = 0;
/**
* @slot icon - The element shown for the seek backward buttons display.
*
* @attr {string} seekoffset - Adjusts how much time (in seconds) the playhead should seek backward.
* @attr {string} mediacurrenttime - (read-only) Set to the current media time.
*
* @cssproperty [--media-seek-backward-button-display = inline-flex] - `display` property of button.
*/
class MediaSeekBackwardButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_CURRENT_TIME,
Attributes.SEEK_OFFSET,
];
}
constructor(options = {}) {
super({
slotTemplate,
tooltipContent: tooltipLabels.SEEK_BACKWARD,
...options,
});
}
connectedCallback(): void {
this.seekOffset = getNumericAttr(
this,
Attributes.SEEK_OFFSET,
DEFAULT_SEEK_OFFSET
);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
_oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.SEEK_OFFSET) {
this.seekOffset = getNumericAttr(
this,
Attributes.SEEK_OFFSET,
DEFAULT_SEEK_OFFSET
);
}
super.attributeChangedCallback(attrName, _oldValue, newValue);
}
// Own props
/**
* Seek amount in seconds
*/
get seekOffset(): number {
return getNumericAttr(this, Attributes.SEEK_OFFSET, DEFAULT_SEEK_OFFSET);
}
set seekOffset(value: number) {
setNumericAttr(this, Attributes.SEEK_OFFSET, value);
this.setAttribute(
'aria-label',
verbs.SEEK_BACK_N_SECS({ seekOffset: this.seekOffset })
);
updateIconText(getSlotted(this, 'icon'), this.seekOffset as any);
}
// Props derived from Media UI Attributes
/**
* The current time in seconds
*/
get mediaCurrentTime(): number {
return getNumericAttr(
this,
MediaUIAttributes.MEDIA_CURRENT_TIME,
DEFAULT_TIME
);
}
set mediaCurrentTime(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, time);
}
handleClick(): void {
const detail = Math.max(this.mediaCurrentTime - this.seekOffset, 0);
const evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
}
}
if (!globalThis.customElements.get('media-seek-backward-button')) {
globalThis.customElements.define(
'media-seek-backward-button',
MediaSeekBackwardButton
);
}
export default MediaSeekBackwardButton;

View File

@@ -0,0 +1,126 @@
import { MediaChromeButton } from './media-chrome-button.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { getNumericAttr, setNumericAttr } from './utils/element-utils.js';
import { tooltipLabels, verbs } from './labels/labels.js';
import { getSlotted, updateIconText } from './utils/element-utils.js';
export const Attributes = {
SEEK_OFFSET: 'seekoffset',
};
const DEFAULT_SEEK_OFFSET = 30;
const forwardIcon = `<svg aria-hidden="true" viewBox="0 0 20 24"><defs><style>.text{font-size:8px;font-family:Arial-BoldMT, Arial;font-weight:700;}</style></defs><text class="text value" transform="translate(8.9 19.87)">${DEFAULT_SEEK_OFFSET}</text><path d="M10 6V3l5.61 4L10 10.94V8a5.54 5.54 0 0 0-1.9 10.48v2.12A7.5 7.5 0 0 1 10 6Z"/></svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = `
<slot name="icon">${forwardIcon}</slot>
`;
const DEFAULT_TIME = 0;
/**
* @slot icon - The element shown for the seek forward buttons display.
*
* @attr {string} seekoffset - Adjusts how much time (in seconds) the playhead should seek forward.
* @attr {string} mediacurrenttime - (read-only) Set to the current media time.
*
* @cssproperty [--media-seek-forward-button-display = inline-flex] - `display` property of button.
*/
class MediaSeekForwardButton extends MediaChromeButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_CURRENT_TIME,
Attributes.SEEK_OFFSET,
];
}
constructor(options = {}) {
super({
slotTemplate,
tooltipContent: tooltipLabels.SEEK_FORWARD,
...options,
});
}
connectedCallback(): void {
this.seekOffset = getNumericAttr(
this,
Attributes.SEEK_OFFSET,
DEFAULT_SEEK_OFFSET
);
super.connectedCallback();
}
attributeChangedCallback(
attrName: string,
_oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.SEEK_OFFSET) {
this.seekOffset = getNumericAttr(
this,
Attributes.SEEK_OFFSET,
DEFAULT_SEEK_OFFSET
);
}
super.attributeChangedCallback(attrName, _oldValue, newValue);
}
// Own props
/**
* Seek amount in seconds
*/
get seekOffset(): number {
return getNumericAttr(this, Attributes.SEEK_OFFSET, DEFAULT_SEEK_OFFSET);
}
set seekOffset(value: number) {
setNumericAttr(this, Attributes.SEEK_OFFSET, value);
this.setAttribute(
'aria-label',
verbs.SEEK_FORWARD_N_SECS({ seekOffset: this.seekOffset })
);
updateIconText(getSlotted(this, 'icon'), this.seekOffset as any);
}
// Props derived from Media UI Attributes
/**
* The current time in seconds
*/
get mediaCurrentTime(): number {
return getNumericAttr(
this,
MediaUIAttributes.MEDIA_CURRENT_TIME,
DEFAULT_TIME
);
}
set mediaCurrentTime(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, time);
}
handleClick(): void {
const detail = this.mediaCurrentTime + this.seekOffset;
const evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
}
}
if (!globalThis.customElements.get('media-seek-forward-button')) {
globalThis.customElements.define(
'media-seek-forward-button',
MediaSeekForwardButton
);
}
export default MediaSeekForwardButton;

View File

@@ -0,0 +1,560 @@
/**
*
* MediaStore is a way to model media state (and changes to it) in a framework- and DOM-agnostic way. Like the difference between Redux
* (the core state manager) and the Redux react wrapper, MediaStore provides the primitive for aggregating media state together in one place.
*
* It receives events as media state change requests (like `mediaplayrequest`) and keeps an internal representation of the complete media
* state after they change, as opposed to querying the media state sources directly every time it needs to check what state something is in.
*
* It doesn't "know" how to update or query the StateOwners itself (like the media element). Rather, it relies on the StateMediator as an interface
* for getting and setting state and relies on the RequestMap as an interface for translating state change requests to state updates (typically also
* deferring to the StateMediator for setting state on the relevant StateOwners).
*
* Additionally, MediaStore state is not optimistically stored when a state change request is dispatched to it. It instead defers to the StateMediator,
* waiting for events from the StateOwners before checking if the state actually changed and only then committing it to its internal representation of MediaState.
*
* @module media-store
*/
import {
stateMediator as defaultStateMediator,
prepareStateOwners,
StateMediator,
EventOrAction,
} from './state-mediator.js';
import { areValuesEq } from './util.js';
import { requestMap as defaultRequestMap, RequestMap } from './request-map.js';
/**
* MediaState is a full representation of all media-related state modeled by the MediaStore and its StateMediator.
* Instead of checking the StateOwners' state directly or on the fly, MediaStore keeps a "snapshot" of the latest
* state, which will be provided to any MediaStore subscribers whenever the state changes, and is arbitrarily retrievable
* from the MediaStore using `getState()`.
*/
export type MediaState = Readonly<{
[K in keyof StateMediator]: ReturnType<StateMediator[K]['get']>;
}> & {
mediaPreviewTime: number;
mediaPreviewImage: string;
mediaPreviewCoords: [string, string, string, string];
};
/**
* MediaStore is the primary abstraction for managing and monitoring media state and other state relevant to the media UI
* (for example, fullscreen behavior or the availability of media-related functionality for a particular browser or runtime, such as volume control or Airplay). This includes:
* - Keeping track of any state changes (examples: Is the media muted? Is the currently playing media live or on demand? What audio tracks are available for the current media?)
* - Sharing the latest state with any MediaStore subscribers whenever it changes
* - Receiving and responding to requests to change the media or related state (examples: I would like the media to be unmuted. I want to start casting now. I want to switch from English subtitles to Japanese.)
* - Wiring up and managing the relationships between media state, media state change requests, and the stateful entities that “own” the majority of this state (examples: the current media element being used, the current root node, the current fullscreen element)
* - Respecting and monitoring changes in certain optional behaviors that impact state or state change requests (examples: I want subtitles/closed captions to be on by default whenever media with them are loaded. I want to disable keeping track of the users preferred volume level.)
*
* @example &lt;caption>Basic Usage.&lt;/caption>
* const mediaStore = createStore({
* media: myVideoElement,
* fullscreenElement: myMediaUIContainerElement,
* // documentElement: advancedRootNodeCase // Will default to `document`
* options: {
* defaultSubtitles: true // enable subtitles/captions by default
* },
* });
*
* // NOTE: In a more realistic example, `myToggleMutedButton` and `mySeekForwardButton` would likely keep track of/"own" its current state. See, e.g. the `<mute-button>` Media Chrome Web Component.
* const unsubscribe = mediaStore.subscribe(state => {
* myToggleMutedButton.textContent = state.muted ? 'Unmute' : 'Mute';
* });
*
* myToggleMutedButton.addEventListener('click', () => {
* const type = mediaStore.getState().muted ? 'mediaunmuterequest' : 'mediamuterequest'
* mediaStore.dispatch({ type });
* });
*
* mySeekForwardButton.addEventListener('click', () => {
* mediaStore.dispatch({
* type: 'mediaseekrequest',
* // NOTE: For all of our state change requests that require additional information, we rely on the `detail` property so we can conform to `CustomEvent`, making interop easier.
* detail: mediaStore.getState().mediaCurrentTime + 15,
* });
* });
*
* // If your code has cases where it swaps out the media element being used
* mediaStore.dispatch({
* type: 'mediaelementchangerequest',
* detail: myAudioElement,
* });
*
* // ... Eventual teardown, when relevant. This is especially relevant for potential garbage collection/memory management considerations.
* unsubscribe();
*
*/
export type MediaStore = {
/**
* A method that expects an "Action" or "Event".Primarily used to make state change requests.
*/
dispatch(eventOrAction: EventOrAction<any>): void;
/**
* A method to get the current state of the MediaStore
*/
getState(): Partial<MediaState>;
/**
* A method to "subscribe" to the MediaStore. A subscriber is just a callback function that is invoked with the current state whenever the MediaStore's state changes. The method returns an "unsubscribe" function, which should be used to tell the MediaStore to remove the corresponding subscriber.
*/
subscribe(handler: (state: Partial<MediaState>) => void): () => void;
};
type MediaStoreConfig = {
media?: any;
fullscreenElement?: any;
documentElement?: any;
stateMediator?: StateMediator;
requestMap?: RequestMap;
options?: any;
monitorStateOwnersOnlyWithSubscriptions?: boolean;
};
/**
* A factory for creating a `MediaStore` instance.
* @param mediaStoreConfig - Configuration object for the `MediaStore`.
*/
const createMediaStore = ({
media,
fullscreenElement,
documentElement,
stateMediator = defaultStateMediator,
requestMap = defaultRequestMap,
options = {},
monitorStateOwnersOnlyWithSubscriptions = true,
}: MediaStoreConfig): MediaStore => {
const callbacks = [];
// We may eventually want to expose the state owners as part of the state
// or as a specialized getter API for advanced use cases
/** @type {StateOwners} */
const stateOwners: any = {
// Spreading options here since folks should not rely on holding onto references
// for any app-level logic wrt options.
options: { ...options },
};
/** @TODO How to model initial state for values not (currently) provided via the facade? (CJP) */
/**
* @type {Partial<MediaState>}
*/
let state = Object.freeze({
mediaPreviewTime: undefined,
mediaPreviewImage: undefined,
mediaPreviewCoords: undefined,
mediaPreviewChapter: undefined,
});
const updateState = (nextStateDelta: any) => {
// This function is generically invoked, even if there are
// no direct state updates. In those cases, simply bail early. (CJP)
if (nextStateDelta == undefined) return;
if (areValuesEq(nextStateDelta, state)) {
return;
}
// Update the state since it changed.
// Using an "immutable" approach here so
// callbacks can easily do comparisons between prev/next state.
// Freezing isn't necessary, though it's a light touch enforcement
// of immutability (in case folks try to directly modify state)
state = Object.freeze({
...state,
...nextStateDelta,
});
// Given anything that cares the updated state
callbacks.forEach((cb) => cb(state));
};
const updateStateFromFacade = () => {
const nextState = Object.entries(stateMediator).reduce(
(nextState, [stateName, { get }]) => {
// (re)initialize state based on current derived state of facade
// NOTE: Since we don't know what stateOwners are tied to deriving a particular state,
// we should update this if *any* state owner changed. (CJP)
nextState[stateName] = get(stateOwners);
return nextState;
},
{}
);
// since a bunch of state likely changed, update with the latest computed values
updateState(nextState);
};
// Dictionary for event handler storage and cleanup
const stateUpdateHandlers = {};
// This function will handle all wiring up of event handlers/monitoring of state
// and will re-compute the general next state whenever any "state owner" is set or updated,
// which includes the media element, but also the documentElement and the fullscreenElement
// This is roughly equivalent to what used to be in `mediaSetCallback`/`mediaUnsetCallback` (CJP)
let nextStateOwners = undefined;
const updateStateOwners = async (
nextStateOwnersDelta: any,
nextSubscriberCount?: number
) => {
const pendingUpdate = !!nextStateOwners;
nextStateOwners = {
...stateOwners,
...(nextStateOwners ?? {}),
...nextStateOwnersDelta,
};
if (pendingUpdate) return;
await prepareStateOwners(...Object.values(nextStateOwnersDelta));
// Define all of the disparate stateOwner monitoring teardown/setup once, up front.
// To avoid memory leaks, MediaStores can be configured to only monitor if
// there's at least one subscriber (callback). If they're configured this way,
// that means they should teardown pre-existing monitoring (e.g. event handlers)
// whenever the subscribers "head count" goes from > 0 to 0.
const shouldTeardownFromSubscriberCount =
callbacks.length > 0 &&
nextSubscriberCount === 0 &&
monitorStateOwnersOnlyWithSubscriptions;
// These define whether a particular `stateOwner` (or "sub-owner", e.g. media.textTracks)
// has changed since the last time this function was invoked. Relevant for both
// teardown and setup logic.
const mediaChanged = stateOwners.media !== nextStateOwners.media;
const textTracksChanged =
stateOwners.media?.textTracks !== nextStateOwners.media?.textTracks;
const videoRenditionsChanged =
stateOwners.media?.videoRenditions !==
nextStateOwners.media?.videoRenditions;
const audioTracksChanged =
stateOwners.media?.audioTracks !== nextStateOwners.media?.audioTracks;
const remoteChanged =
stateOwners.media?.remote !== nextStateOwners.media?.remote;
const rootNodeChanged =
stateOwners.documentElement !== nextStateOwners.documentElement;
// For any particular `stateOwner` (or "sub-owner"), we should teardown if and only if:
// * the `stateOwner` existed -AND-
// * it either changed -OR-
// * we are configured to stop monitoring due to the subscriber "head count".
const teardownMedia =
!!stateOwners.media &&
(mediaChanged || shouldTeardownFromSubscriberCount);
const teardownTextTracks =
!!stateOwners.media?.textTracks &&
(textTracksChanged || shouldTeardownFromSubscriberCount);
const teardownVideoRenditions =
!!stateOwners.media?.videoRenditions &&
(videoRenditionsChanged || shouldTeardownFromSubscriberCount);
const teardownAudioTracks =
!!stateOwners.media?.audioTracks &&
(audioTracksChanged || shouldTeardownFromSubscriberCount);
const teardownRemote =
!!stateOwners.media?.remote &&
(remoteChanged || shouldTeardownFromSubscriberCount);
const teardownRootNode =
!!stateOwners.documentElement &&
(rootNodeChanged || shouldTeardownFromSubscriberCount);
// This is simply a convenience definition saying we should be tearing down *something*
// used for short circuiting conditions.
const teardownSomething =
teardownMedia ||
teardownTextTracks ||
teardownVideoRenditions ||
teardownAudioTracks ||
teardownRemote ||
teardownRootNode;
// To avoid memory leaks, MediaStores can be configured to only monitor if
// there's at least one subscriber (callback). If they're configured this way,
// that means they should teardown pre-existing monitoring (e.g. event handlers)
// whenever the subscribers "head count" goes from > 0 to 0.
const shouldSetupFromSubscriberCount =
callbacks.length === 0 &&
nextSubscriberCount === 1 &&
monitorStateOwnersOnlyWithSubscriptions;
// For any particular `stateOwner` (or "sub-owner"), we should setup if and only if:
// * the new `stateOwner` exists (or is not being replaced) -AND-
// * it changed -OR-
// * we are configured to start monitoring due to the subscriber "head count".
const setupMedia =
!!nextStateOwners.media &&
(mediaChanged || shouldSetupFromSubscriberCount);
const setupTextTracks =
!!nextStateOwners.media?.textTracks &&
(textTracksChanged || shouldSetupFromSubscriberCount);
const setupVideoRenditions =
!!nextStateOwners.media?.videoRenditions &&
(videoRenditionsChanged || shouldSetupFromSubscriberCount);
const setupAudioTracks =
!!nextStateOwners.media?.audioTracks &&
(audioTracksChanged || shouldSetupFromSubscriberCount);
const setupRemote =
!!nextStateOwners.media?.remote &&
(remoteChanged || shouldSetupFromSubscriberCount);
const setupRootNode =
!!nextStateOwners.documentElement &&
(rootNodeChanged || shouldSetupFromSubscriberCount);
// This is simply a convenience definition saying we should be setting up *something*
// used for short circuiting conditions.
const setupSomething =
setupMedia ||
setupTextTracks ||
setupVideoRenditions ||
setupAudioTracks ||
setupRemote ||
setupRootNode;
const somethingToDo = teardownSomething || setupSomething;
// If there's nothing to do (teardown- or setup-wise), we're done here.
if (!somethingToDo) {
// Except make sure we actually update the stateOwners, if changed
Object.entries(nextStateOwners).forEach(
([stateOwnerName, stateOwner]) => {
stateOwners[stateOwnerName] = stateOwner;
}
);
updateStateFromFacade();
nextStateOwners = undefined;
return;
}
Object.entries(stateMediator).forEach(
([
stateName,
{
get,
mediaEvents = [],
textTracksEvents = [],
videoRenditionsEvents = [],
audioTracksEvents = [],
remoteEvents = [],
rootEvents = [],
stateOwnersUpdateHandlers = [],
},
]) => {
// NOTE: This should probably be pulled out into a one-time initialization (CJP)
if (!stateUpdateHandlers[stateName]) {
stateUpdateHandlers[stateName] = {};
}
const handler = (event) => {
const nextValue = get(stateOwners, event);
updateState({ [stateName]: nextValue });
};
let prevHandler;
// Media Changed, update handlers here
prevHandler = stateUpdateHandlers[stateName].mediaEvents;
mediaEvents.forEach((eventType) => {
if (prevHandler && teardownMedia) {
stateOwners.media.removeEventListener(eventType, prevHandler);
stateUpdateHandlers[stateName].mediaEvents = undefined;
}
if (setupMedia) {
nextStateOwners.media.addEventListener(eventType, handler);
stateUpdateHandlers[stateName].mediaEvents = handler;
}
});
prevHandler = stateUpdateHandlers[stateName].textTracksEvents;
textTracksEvents.forEach((eventType) => {
if (prevHandler && teardownTextTracks) {
stateOwners.media.textTracks?.removeEventListener(
eventType,
prevHandler
);
stateUpdateHandlers[stateName].textTracksEvents = undefined;
}
if (setupTextTracks) {
nextStateOwners.media.textTracks?.addEventListener(
eventType,
handler
);
stateUpdateHandlers[stateName].textTracksEvents = handler;
}
});
prevHandler = stateUpdateHandlers[stateName].videoRenditionsEvents;
videoRenditionsEvents.forEach((eventType) => {
if (prevHandler && teardownVideoRenditions) {
stateOwners.media.videoRenditions?.removeEventListener(
eventType,
prevHandler
);
stateUpdateHandlers[stateName].videoRenditionsEvents = undefined;
}
if (setupVideoRenditions) {
nextStateOwners.media.videoRenditions?.addEventListener(
eventType,
handler
);
stateUpdateHandlers[stateName].videoRenditionsEvents = handler;
}
});
prevHandler = stateUpdateHandlers[stateName].audioTracksEvents;
audioTracksEvents.forEach((eventType) => {
if (prevHandler && teardownAudioTracks) {
stateOwners.media.audioTracks?.removeEventListener(
eventType,
prevHandler
);
stateUpdateHandlers[stateName].audioTracksEvents = undefined;
}
if (setupAudioTracks) {
nextStateOwners.media.audioTracks?.addEventListener(
eventType,
handler
);
stateUpdateHandlers[stateName].audioTracksEvents = handler;
}
});
prevHandler = stateUpdateHandlers[stateName].remoteEvents;
remoteEvents.forEach((eventType) => {
if (prevHandler && teardownRemote) {
stateOwners.media.remote?.removeEventListener(
eventType,
prevHandler
);
stateUpdateHandlers[stateName].remoteEvents = undefined;
}
if (setupRemote) {
nextStateOwners.media.remote?.addEventListener(eventType, handler);
stateUpdateHandlers[stateName].remoteEvents = handler;
}
});
prevHandler = stateUpdateHandlers[stateName].rootEvents;
rootEvents.forEach((eventType) => {
if (prevHandler && teardownRootNode) {
stateOwners.documentElement.removeEventListener(
eventType,
prevHandler
);
stateUpdateHandlers[stateName].rootEvents = undefined;
}
if (setupRootNode) {
nextStateOwners.documentElement.addEventListener(
eventType,
handler
);
stateUpdateHandlers[stateName].rootEvents = handler;
}
});
// NOTE: Since custom update handlers may depend on *any* state owner
// we should apply them whenever any state owner changes (CJP)
const prevHandlerTeardown =
stateUpdateHandlers[stateName].stateOwnersUpdateHandlers;
stateOwnersUpdateHandlers.forEach((fn) => {
if (prevHandlerTeardown && teardownSomething) {
prevHandlerTeardown();
}
if (setupSomething) {
stateUpdateHandlers[stateName].stateOwnersUpdateHandlers = fn(
handler,
nextStateOwners
);
}
});
}
);
Object.entries(nextStateOwners).forEach(([stateOwnerName, stateOwner]) => {
stateOwners[stateOwnerName] = stateOwner;
});
updateStateFromFacade();
nextStateOwners = undefined;
};
updateStateOwners({ media, fullscreenElement, documentElement, options });
return {
// note that none of these cases directly interact with the media element, root node, full screen element, etc.
// note these "actions" could just be the events if we wanted, especially if we normalize on "detail" for
// any payload-relevant values
// This is roughly equivalent to our used to be in our state requests dictionary object, though much of the
// "heavy lifting" is now moved into the facade `set()`
dispatch(action) {
const { type, detail } = action;
// For any state change request "actions"/"events" of media (and related) state,
// these are handled by the `RequestMap`, which defines a function for a given change request type
// that is responsible for what should happen as a result
if (requestMap[type]) {
// Most state change requests do not directly update the media state. Instead
// they will typically interact in some way or another with one or more of the `StateOwner`s (like the media element).
// For some of our media UI state, however, it does directly update state. In those cases,
// the function can optionally return an object with the properties and values of the media state changes.
// See: RequestMap[MediaUIEvents.MEDIA_PREVIEW_REQUEST] for an example of this.
updateState(requestMap[type](stateMediator, stateOwners, action));
return;
}
// These are other state change requests so we can dynamically update things like the media element, fullscreenElement,
// or options-style properties in a single architecture.
// We can get change requests for the stateOwners themselves
if (type === 'mediaelementchangerequest') {
updateStateOwners({ media: detail });
} else if (type === 'fullscreenelementchangerequest') {
updateStateOwners({ fullscreenElement: detail });
} else if (type === 'documentelementchangerequest') {
updateStateOwners({ documentElement: detail });
}
// and we can update our default/options values
else if (type === 'optionschangerequest') {
// Doing a simple impl for now
Object.entries(detail ?? {}).forEach(([optionName, optionValue]) => {
// NOTE: updating options will *NOT* prompt any state updates.
// However, since we directly mutate options, this allows state owners to be
// "live" and automatically updated for any other event or similar monitoring.
// For a concrete example, see, e.g., the `mediaSubtitlesShowing.stateOwnersUpdateHandlers`
// responsible for managing/monitoring `defaultSubtitles` in the `defaultStateMediator`. (CJP)
stateOwners.options[optionName] = optionValue;
});
// updateStateFromFacade();
}
},
getState() {
// return the current state, whatever it is
return state;
},
subscribe(callback) {
// Since state owner monitoring can change based on subscription "head count",
// make sure we invoke `updateStateOwners()` whenever someone subscribes.
// NOTE: Must do this before updating `callbacks` to compare next vs. previous callback count.
updateStateOwners({}, callbacks.length + 1);
callbacks.push(callback);
// give the callback the current state immediately so it can get whatever the state is currently.
callback(state);
return () => {
const idx = callbacks.indexOf(callback);
if (idx >= 0) {
// Since state owner monitoring can change based on subscription "head count",
// make sure we invoke `updateStateOwners()` whenever someone unsubscribes.
// NOTE: Must do this before updating `callbacks` to compare next vs. previous callback count.
updateStateOwners({}, callbacks.length - 1);
callbacks.splice(idx, 1);
}
};
},
};
};
export default createMediaStore;

View File

@@ -0,0 +1,298 @@
import { globalThis } from '../utils/server-safe-globals.js';
import {
MediaUIEvents,
StreamTypes,
TextTrackKinds,
TextTrackModes,
} from '../constants.js';
import {
getTextTracksList,
parseTracks,
updateTracksModeTo,
} from '../utils/captions.js';
import { getSubtitleTracks, toggleSubtitleTracks } from './util.js';
import { StateMediator, StateOwners } from './state-mediator.js';
import { MediaState } from './media-store.js';
export type MediaUIEventsType =
typeof MediaUIEvents[keyof typeof MediaUIEvents];
export type MediaRequestTypes = Exclude<
MediaUIEventsType,
| 'registermediastatereceiver'
| 'unregistermediastatereceiver'
| 'mediashowtexttracksrequest'
| 'mediahidetexttracksrequest'
>;
/** @TODO Make this definition more precise (CJP) */
/**
*
* RequestMap provides a stateless, well-defined API for translating state change requests to related side effects to attempt to fulfill said request and
* any other appropriate state changes that should occur as a result. Most often (but not always), those will simply rely on the StateMediator's `set()`
* method for the corresponding state to update the StateOwners state. RequestMap is designed to be used by a MediaStore, which owns all of the wiring up
* and persistence of e.g. StateOwners, MediaState, StateMediator, and the RequestMap.
*
* For any modeled state change request, the RequestMap defines a key, K, which directly maps to the state change request type (e.g. `mediapauserequest`, `mediaseekrequest`, etc.),
* whose value is a function that defines the appropriate side effects of the request that will, under normal circumstances, (eventually) result in actual state changes.
*/
export type RequestMap = {
[K in MediaRequestTypes]: (
stateMediator: StateMediator,
stateOwners: StateOwners,
action: Partial<Pick<CustomEvent<any>, 'type' | 'detail'>>
) => Partial<MediaState> | undefined | void;
};
export const requestMap: RequestMap = {
/**
* @TODO Consider adding state to `StateMediator` for e.g. `mediaThumbnailCues` and use that for derived state here (CJP)
*/
[MediaUIEvents.MEDIA_PREVIEW_REQUEST](
stateMediator,
stateOwners,
{ detail }
) {
const { media } = stateOwners;
const mediaPreviewTime = detail ?? undefined;
let mediaPreviewImage = undefined;
let mediaPreviewCoords = undefined;
// preview-related state should be reset to nothing
// when there is no media or the preview time request is null/undefined
if (media && mediaPreviewTime != null) {
// preview thumbnail image-related derivation
const [track] = getTextTracksList(media as HTMLVideoElement, {
kind: TextTrackKinds.METADATA,
label: 'thumbnails',
});
const cue = Array.prototype.find.call(track?.cues ?? [], (c, i, cs) => {
// If our first preview image cue ends after mediaPreviewTime, use it.
if (i === 0) return c.endTime > mediaPreviewTime;
// If our last preview image cue ends at or before mediaPreviewTime, use it.
if (i === cs.length - 1) return c.startTime <= mediaPreviewTime;
// Otherwise, use the cue that contains mediaPreviewTime
return c.startTime <= mediaPreviewTime && c.endTime > mediaPreviewTime;
});
if (cue) {
const base = !/'^(?:[a-z]+:)?\/\//i.test(cue.text)
? (
media?.querySelector(
'track[label="thumbnails"]'
) as HTMLTrackElement
)?.src
: undefined;
const url = new URL(cue.text, base);
const previewCoordsStr = new URLSearchParams(url.hash).get('#xywh');
mediaPreviewCoords = previewCoordsStr
.split(',')
.map((numStr) => +numStr) as [number, number, number, number];
mediaPreviewImage = url.href;
}
}
const mediaDuration = stateMediator.mediaDuration.get(stateOwners);
// chapters cue text
const mediaChaptersCues = stateMediator.mediaChaptersCues.get(stateOwners);
let mediaPreviewChapter = mediaChaptersCues.find((c, i, cs) => {
// Since Chapters may be "gappy", only treat the endtime as inclusive
// if it is the last chapter cue and that cue ends when the entire media ends
if (i === cs.length - 1 && mediaDuration === c.endTime) {
return c.startTime <= mediaPreviewTime && c.endTime >= mediaPreviewTime;
}
return c.startTime <= mediaPreviewTime && c.endTime > mediaPreviewTime;
})?.text;
// If the chapter is not found but the detail (preview time) is defined
// set the chapter to an empty string to differentiate it from undefined.
if (detail != null && mediaPreviewChapter == null) {
mediaPreviewChapter = '';
}
// NOTE: Example of directly updating state from a request action/event (CJP)
return {
mediaPreviewTime,
mediaPreviewImage,
mediaPreviewCoords,
mediaPreviewChapter,
};
},
[MediaUIEvents.MEDIA_PAUSE_REQUEST](stateMediator, stateOwners) {
const key = 'mediaPaused';
const value = true;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_PLAY_REQUEST](stateMediator, stateOwners) {
const key = 'mediaPaused';
const value = false;
const live =
stateMediator.mediaStreamType.get(stateOwners) === StreamTypes.LIVE;
if (live) {
const notDvr = !(
stateMediator.mediaTargetLiveWindow.get(stateOwners) > 0
);
const liveEdgeTime = stateMediator.mediaSeekable.get(stateOwners)?.[1];
// Only seek to live if we are live, not DVR, and have a known seekable end
if (notDvr && liveEdgeTime) {
stateMediator.mediaCurrentTime.set(liveEdgeTime, stateOwners);
}
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_PLAYBACK_RATE_REQUEST](
stateMediator,
stateOwners,
{ detail }
) {
const key = 'mediaPlaybackRate';
const value = detail;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_MUTE_REQUEST](stateMediator, stateOwners) {
const key = 'mediaMuted';
const value = true;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_UNMUTE_REQUEST](stateMediator, stateOwners) {
const key = 'mediaMuted';
const value = false;
// If we've unmuted but our volume is currently 0, automatically set it to some low volume
if (!stateMediator.mediaVolume.get(stateOwners)) {
stateMediator.mediaVolume.set(0.25, stateOwners);
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_VOLUME_REQUEST](stateMediator, stateOwners, { detail }) {
const key = 'mediaVolume';
const value = detail;
// If we've adjusted the volume to some non-0 number and are muted, automatically unmute.
// NOTE: "pseudo-muted" is currently modeled via MEDIA_VOLUME_LEVEL === "off" (CJP)
if (value && stateMediator.mediaMuted.get(stateOwners)) {
stateMediator.mediaMuted.set(false, stateOwners);
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_SEEK_REQUEST](stateMediator, stateOwners, { detail }) {
const key = 'mediaCurrentTime';
const value = detail;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_SEEK_TO_LIVE_REQUEST](stateMediator, stateOwners) {
// This is an example of a specialized state change request "action" that doesn't need a specialized
// state facade model
const key = 'mediaCurrentTime';
const value = stateMediator.mediaSeekable.get(stateOwners)?.[1];
// If we don't have a known seekable end (which represents the live edge), bail early
if (Number.isNaN(Number(value))) return;
stateMediator[key].set(value, stateOwners);
},
// Text Tracks state change requests
[MediaUIEvents.MEDIA_SHOW_SUBTITLES_REQUEST](
_stateMediator,
stateOwners,
{ detail }
) {
const { options } = stateOwners;
const tracks = getSubtitleTracks(stateOwners);
const tracksToUpdate = parseTracks(detail);
const preferredLanguage = tracksToUpdate[0]?.language;
if (preferredLanguage && !options.noSubtitlesLangPref) {
globalThis.localStorage.setItem(
'media-chrome-pref-subtitles-lang',
preferredLanguage
);
}
updateTracksModeTo(TextTrackModes.SHOWING, tracks, tracksToUpdate);
},
[MediaUIEvents.MEDIA_DISABLE_SUBTITLES_REQUEST](
_stateMediator,
stateOwners,
{ detail }
) {
const tracks = getSubtitleTracks(stateOwners);
const tracksToUpdate = detail ?? [];
updateTracksModeTo(TextTrackModes.DISABLED, tracks, tracksToUpdate);
},
[MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST](
_stateMediator,
stateOwners,
{ detail }
) {
toggleSubtitleTracks(stateOwners, detail);
},
// Renditions/Tracks state change requests
[MediaUIEvents.MEDIA_RENDITION_REQUEST](
stateMediator,
stateOwners,
{ detail }
) {
const key = 'mediaRenditionSelected';
const value = detail;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_AUDIO_TRACK_REQUEST](
stateMediator,
stateOwners,
{ detail }
) {
const key = 'mediaAudioTrackEnabled';
const value = detail;
stateMediator[key].set(value, stateOwners);
},
// State change requests dependent on root node
[MediaUIEvents.MEDIA_ENTER_PIP_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsPip';
const value = true;
// Exit fullscreen if in fullscreen and entering PiP
if (stateMediator.mediaIsFullscreen.get(stateOwners)) {
// Should be async
stateMediator.mediaIsFullscreen.set(false, stateOwners);
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_EXIT_PIP_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsPip';
const value = false;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsFullscreen';
const value = true;
// Exit PiP if in PiP and entering fullscreen
if (stateMediator.mediaIsPip.get(stateOwners)) {
// Should be async
stateMediator.mediaIsPip.set(false, stateOwners);
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsFullscreen';
const value = false;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_ENTER_CAST_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsCasting';
const value = true;
// Exit fullscreen if in fullscreen and attempting to cast
if (stateMediator.mediaIsFullscreen.get(stateOwners)) {
// Should be async
stateMediator.mediaIsFullscreen.set(false, stateOwners);
}
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_EXIT_CAST_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsCasting';
const value = false;
stateMediator[key].set(value, stateOwners);
},
[MediaUIEvents.MEDIA_AIRPLAY_REQUEST](stateMediator, stateOwners) {
const key = 'mediaIsAirplaying';
const value = true;
stateMediator[key].set(value, stateOwners);
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import { TextTrackKinds, TextTrackModes } from '../constants.js';
import { getTextTracksList, updateTracksModeTo } from '../utils/captions.js';
import { TextTrackLike } from '../utils/TextTrackLike.js';
export const getSubtitleTracks = (stateOwners): TextTrackLike[] => {
return getTextTracksList(stateOwners.media, (textTrack) => {
return [TextTrackKinds.SUBTITLES, TextTrackKinds.CAPTIONS].includes(
textTrack.kind as any
);
}).sort((a, b) => (a.kind >= b.kind ? 1 : -1));
};
export const getShowingSubtitleTracks = (stateOwners): TextTrackLike[] => {
return getTextTracksList(stateOwners.media, (textTrack) => {
return (
textTrack.mode === TextTrackModes.SHOWING &&
[TextTrackKinds.SUBTITLES, TextTrackKinds.CAPTIONS].includes(
textTrack.kind as any
)
);
});
};
export const toggleSubtitleTracks = (stateOwners, force: boolean): void => {
// NOTE: Like Element::toggleAttribute(), this event uses the detail for an optional "force"
// value. When present, this means "toggle to" "on" (aka showing, even if something's already showing)
// or "off" (aka disabled, even if all tracks are currently disabled).
// See, e.g.: https://developer.mozilla.org/en-US/docs/Web/API/Element/toggleAttribute#force (CJP)
// NOTE: Like Element::toggleAttribute(), this event uses the detail for an optional "force"
// value. When present, this means "toggle to" "on" (aka showing, even if something's already showing)
// or "off" (aka disabled, even if all tracks are currently disabled).
// See, e.g.: https://developer.mozilla.org/en-US/docs/Web/API/Element/toggleAttribute#force (CJP)
const tracks = getSubtitleTracks(stateOwners);
const showingSubitleTracks = getShowingSubtitleTracks(stateOwners);
const subtitlesShowing = !!showingSubitleTracks.length;
// If there are no tracks, this request doesn't matter, so we're done.
if (!tracks.length) return;
// NOTE: not early bailing on forced cases so we may pick up async cases of toggling on, particularly for HAS-style
// (e.g. HLS) media where we may not get our preferred subtitles lang until later (CJP)
if (force === false || (subtitlesShowing && force !== true)) {
updateTracksModeTo(TextTrackModes.DISABLED, tracks, showingSubitleTracks);
} else if (force === true || (!subtitlesShowing && force !== false)) {
let subTrack = tracks[0];
const { options } = stateOwners;
if (!options?.noSubtitlesLangPref) {
const subtitlesPref = globalThis.localStorage.getItem(
'media-chrome-pref-subtitles-lang'
);
const userLangPrefs = subtitlesPref
? [subtitlesPref, ...globalThis.navigator.languages]
: globalThis.navigator.languages;
const preferredAvailableSubs = tracks
.filter((textTrack) => {
return userLangPrefs.some((lang) =>
textTrack.language.toLowerCase().startsWith(lang.split('-')[0])
);
})
.sort((textTrackA, textTrackB) => {
const idxA = userLangPrefs.findIndex((lang) =>
textTrackA.language.toLowerCase().startsWith(lang.split('-')[0])
);
const idxB = userLangPrefs.findIndex((lang) =>
textTrackB.language.toLowerCase().startsWith(lang.split('-')[0])
);
return idxA - idxB;
});
// Since there may not have been any user preferred subs/cc match, keep the default (picking the first) as
// the subtitle track to show for these cases.
if (preferredAvailableSubs[0]) {
subTrack = preferredAvailableSubs[0];
}
}
const { language, label, kind } = subTrack;
updateTracksModeTo(TextTrackModes.DISABLED, tracks, showingSubitleTracks);
updateTracksModeTo(TextTrackModes.SHOWING, tracks, [
{ language, label, kind },
]);
}
};
export const areValuesEq = (x: any, y: any): boolean => {
// If both are strictly equal, they're equal
if (x === y) return true;
// If their types don't match, they're not equal
if (typeof x !== typeof y) return false;
// Treat NaNs as equal
if (typeof x === 'number' && Number.isNaN(x) && Number.isNaN(y)) return true;
// NOTE: This impl does not support function values (CJP)
// All other "simple" types are not equal, since they have the same type and were not strictly equal
if (typeof x !== 'object') return false;
if (Array.isArray(x)) return areArraysEq(x, y);
// NOTE: This impl currently assumes that if y[key] -> x[key] (aka no "extra" keys in y) (CJP)
// For objects, if every key's value in x has a corresponding key/value entry in y, the objects are equal
return Object.entries(x).every(
// NOTE: Checking key in y to disambiguate between between missing keys and keys whose value are undefined (CJP)
([key, value]) => key in y && areValuesEq(value as number, y[key])
);
};
export const areArraysEq = (xs: number[], ys: number[]): boolean => {
const xIsArray = Array.isArray(xs);
const yIsArray = Array.isArray(ys);
// If one of the "arrays" is not an array, not equal
if (xIsArray !== yIsArray) return false;
// If both of the "arrays" are not arrays, equal
if (!(xIsArray || yIsArray)) return true;
// If arrays have different length, not equal
if (xs.length !== ys.length) return false;
// NOTE: presuming sort order is equivalent (CJP)
// If and only every corresponding entry between the arrays is equal, arrays are equal
return xs.every((x, i) => areValuesEq(x, ys[i]));
};

View File

@@ -0,0 +1,139 @@
import { MediaStateReceiverAttributes } from './constants.js';
import MediaController from './media-controller.js';
import { getOrInsertCSSRule } from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
font: var(--media-font,
var(--media-font-weight, normal)
var(--media-font-size, 14px) /
var(--media-text-content-height, var(--media-control-height, 24px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
background: var(--media-text-background, var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7))));
padding: var(--media-control-padding, 10px);
display: inline-flex;
justify-content: center;
align-items: center;
vertical-align: middle;
box-sizing: border-box;
text-align: center;
pointer-events: auto;
}
${
/*
Only show outline when keyboard focusing.
https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo
*/ ''
}
:host(:focus-visible) {
box-shadow: inset 0 0 0 2px rgb(27 127 204 / .9);
outline: 0;
}
${
/*
* hide default focus ring, particularly when using mouse
*/ ''
}
:host(:where(:focus)) {
box-shadow: none;
outline: 0;
}
</style>
<slot></slot>
`;
/**
* @slot - Default slotted elements.
*
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
*
* @cssproperty --media-primary-color - Default color of text.
* @cssproperty --media-secondary-color - Default color of background.
* @cssproperty --media-text-color - `color` of text.
*
* @cssproperty --media-control-display - `display` property of control.
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-control-padding - `padding` of control.
* @cssproperty --media-control-height - `line-height` of control.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of text.
*/
class MediaTextDisplay extends globalThis.HTMLElement {
#mediaController: MediaController | null;
static get observedAttributes(): string[] {
return [MediaStateReceiverAttributes.MEDIA_CONTROLLER];
}
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
connectedCallback(): void {
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
style.setProperty(
'display',
`var(--media-control-display, var(--${this.localName}-display, inline-flex))`
);
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
// @ts-ignore
this.#mediaController = (this.getRootNode() as Document)?.getElementById(
mediaControllerId
);
this.#mediaController?.associateElement?.(this);
}
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
}
if (!globalThis.customElements.get('media-text-display')) {
globalThis.customElements.define('media-text-display', MediaTextDisplay);
}
export { MediaTextDisplay };
export default MediaTextDisplay;

View File

@@ -0,0 +1,292 @@
import { MediaStateChangeEvents } from './constants.js';
import MediaController from './media-controller.js';
import { getOrInsertCSSRule } from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { TemplateInstance } from './utils/template-parts.js';
import { processor } from './utils/template-processor.js';
import { camelCase, isNumericString } from './utils/utils.js';
// Export Template parts for players.
export * from './utils/template-parts.js';
const observedMediaAttributes = {
mediatargetlivewindow: 'targetlivewindow',
mediastreamtype: 'streamtype',
};
// For hiding the media-theme element until the breakpoints are available
// display: none can't be used because it would prevent the element or its
// children (media-controller) from getting dimensions.
const prependTemplate = document.createElement('template');
prependTemplate.innerHTML = /*html*/ `
<style>
:host {
display: inline-block;
line-height: 0;
/* Hide theme element until the breakpoints are available to avoid flicker. */
visibility: hidden;
}
media-controller {
width: 100%;
height: 100%;
}
media-captions-button:not([mediasubtitleslist]),
media-captions-menu:not([mediasubtitleslist]),
media-captions-menu-button:not([mediasubtitleslist]),
media-audio-track-menu[mediaaudiotrackunavailable],
media-audio-track-menu-button[mediaaudiotrackunavailable],
media-rendition-menu[mediarenditionunavailable],
media-rendition-menu-button[mediarenditionunavailable],
media-volume-range[mediavolumeunavailable],
media-airplay-button[mediaairplayunavailable],
media-fullscreen-button[mediafullscreenunavailable],
media-cast-button[mediacastunavailable],
media-pip-button[mediapipunavailable] {
display: none;
}
</style>
`;
/**
* @extends {HTMLElement}
*
* @attr {string} template - The element `id` of the template to render.
*/
export class MediaThemeElement extends globalThis.HTMLElement {
static template: HTMLTemplateElement;
static observedAttributes = ['template'];
static processor = processor;
renderRoot: ShadowRoot;
renderer?: TemplateInstance;
#template: HTMLTemplateElement;
#prevTemplate: HTMLTemplateElement;
#prevTemplateId: string | null;
constructor() {
super();
if (this.shadowRoot) {
this.renderRoot = this.shadowRoot;
} else {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.renderRoot = this.attachShadow({ mode: 'open' });
this.createRenderer();
}
const observer = new MutationObserver((mutationList) => {
// Only update if `<media-controller>` has computed breakpoints at least once.
if (this.mediaController && !this.mediaController?.breakpointsComputed)
return;
if (
mutationList.some((mutation) => {
const target = mutation.target as HTMLElement;
// Render on each attribute change of the `<media-theme(-x)>` element.
if (target === this) return true;
// Only check `<media-controller>`'s attributes below.
if (target.localName !== 'media-controller') return false;
// Render if this attribute is directly observed.
if (observedMediaAttributes[mutation.attributeName]) return true;
// Render if `breakpointx` attributes change.
if (mutation.attributeName.startsWith('breakpoint')) return true;
return false;
})
) {
this.render();
}
});
// Observe the `<media-theme>` element for attribute changes.
observer.observe(this, { attributes: true });
// Observe the subtree of the render root, by default the elements in the shadow dom.
observer.observe(this.renderRoot, {
attributes: true,
subtree: true,
});
this.addEventListener(
MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
this.render
);
// In case the template prop was set before custom element upgrade.
// https://web.dev/custom-elements-best-practices/#make-properties-lazy
this.#upgradeProperty('template');
}
#upgradeProperty(prop: string): void {
if (Object.prototype.hasOwnProperty.call(this, prop)) {
const value = this[prop];
// Delete the set property from this instance.
delete this[prop];
// Set the value again via the (prototype) setter on this class.
this[prop] = value;
}
}
/** @type {HTMLElement & { breakpointsComputed?: boolean }} */
get mediaController(): MediaController {
// Expose the media controller if API access is needed
return this.renderRoot.querySelector('media-controller');
}
get template() {
return this.#template ?? (this.constructor as typeof MediaThemeElement).template;
}
set template(element) {
this.#prevTemplateId = null;
this.#template = element;
this.createRenderer();
}
get props() {
const observedAttributes = [
...Array.from(this.mediaController?.attributes ?? []).filter(
({ name }) => {
return observedMediaAttributes[name] || name.startsWith('breakpoint');
}
),
...Array.from(this.attributes),
];
const props = {};
for (const attr of observedAttributes) {
const name = observedMediaAttributes[attr.name] ?? camelCase(attr.name);
let { value } = attr;
if (value != null) {
if (isNumericString(value)) {
// @ts-ignore
value = parseFloat(value);
}
props[name] = value === '' ? true : value;
} else {
props[name] = false;
}
}
return props;
}
attributeChangedCallback(
attrName: string,
oldValue: string,
newValue: string | null
): void {
if (attrName === 'template' && oldValue != newValue) {
this.#updateTemplate();
}
}
connectedCallback(): void {
this.#updateTemplate();
}
#updateTemplate(): void {
const templateId = this.getAttribute('template');
if (!templateId || templateId === this.#prevTemplateId) return;
// First try to get a template element by id
const rootNode = this.getRootNode() as Document;
const template = rootNode?.getElementById?.(templateId) as HTMLTemplateElement | null;
if (template) {
// Only save prevTemplateId if a template was found.
this.#prevTemplateId = templateId;
this.#template = template;
this.createRenderer();
return;
}
if (isValidUrl(templateId)) {
// Save prevTemplateId on valid URL before async fetch to prevent duplicate fetch.
this.#prevTemplateId = templateId;
// Next try to fetch a HTML file if it looks like a valid URL.
request(templateId)
.then((data) => {
const template = document.createElement('template');
template.innerHTML = data;
this.#template = template;
this.createRenderer();
})
.catch(console.error);
}
}
createRenderer(): void {
if (this.template && this.template !== this.#prevTemplate) {
this.#prevTemplate = this.template;
this.renderer = new TemplateInstance(
this.template,
this.props,
// @ts-ignore
this.constructor.processor
);
this.renderRoot.textContent = '';
this.renderRoot.append(
prependTemplate.content.cloneNode(true),
this.renderer
);
}
}
render(): void {
this.renderer?.update(this.props);
// The style tag must be connected to the DOM before it has a sheet.
if (this.renderRoot.isConnected) {
const { style } = getOrInsertCSSRule(this.renderRoot, ':host');
if (style.visibility === 'hidden') {
style.removeProperty('visibility');
}
}
}
}
function isValidUrl(url: string): boolean {
// Valid URL e.g. /absolute, ./relative, http://, https://
if (!/^(\/|\.\/|https?:\/\/)/.test(url)) return false;
// Add base if url doesn't start with http:// or https://
const base = /^https?:\/\//.test(url) ? undefined : location.origin;
try {
new URL(url, base);
} catch (e) {
return false;
}
return true;
}
async function request(resource: string | URL | Request): Promise<string> {
const response = await fetch(resource);
if (response.status !== 200) {
throw new Error(
`Failed to load resource: the server responded with a status of ${response.status}`
);
}
return response.text();
}
if (!globalThis.customElements.get('media-theme')) {
globalThis.customElements.define('media-theme', MediaThemeElement);
}

View File

@@ -0,0 +1,289 @@
import { MediaTextDisplay } from './media-text-display.js';
import {
getBooleanAttr,
getNumericAttr,
getOrInsertCSSRule,
setBooleanAttr,
setNumericAttr,
} from './utils/element-utils.js';
import { globalThis } from './utils/server-safe-globals.js';
import { formatAsTimePhrase, formatTime } from './utils/time.js';
import { MediaUIAttributes } from './constants.js';
import { nouns } from './labels/labels.js';
export const Attributes = {
REMAINING: 'remaining',
SHOW_DURATION: 'showduration',
NO_TOGGLE: 'notoggle',
};
const CombinedAttributes = [
...Object.values(Attributes),
MediaUIAttributes.MEDIA_CURRENT_TIME,
MediaUIAttributes.MEDIA_DURATION,
MediaUIAttributes.MEDIA_SEEKABLE,
];
// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
const ButtonPressedKeys = ['Enter', ' '];
const DEFAULT_TIMES_SEP = '&nbsp;/&nbsp;';
const formatTimesLabel = (
el: MediaTimeDisplay,
{ timesSep = DEFAULT_TIMES_SEP } = {}
): string => {
const showRemaining = el.hasAttribute(Attributes.REMAINING);
const showDuration = el.hasAttribute(Attributes.SHOW_DURATION);
const currentTime = el.mediaCurrentTime ?? 0;
const [, seekableEnd] = el.mediaSeekable ?? [];
let endTime = 0;
if (Number.isFinite(el.mediaDuration)) {
endTime = el.mediaDuration;
} else if (Number.isFinite(seekableEnd)) {
endTime = seekableEnd;
}
const timeLabel = showRemaining
? formatTime(0 - (endTime - currentTime))
: formatTime(currentTime);
if (!showDuration) return timeLabel;
return `${timeLabel}${timesSep}${formatTime(endTime)}`;
};
const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time.';
const updateAriaValueText = (el: MediaTimeDisplay): void => {
const currentTime = el.mediaCurrentTime;
const [, seekableEnd] = el.mediaSeekable ?? [];
let endTime = null;
if (Number.isFinite(el.mediaDuration)) {
endTime = el.mediaDuration;
} else if (Number.isFinite(seekableEnd)) {
endTime = seekableEnd;
}
if (currentTime == null || endTime === null) {
el.setAttribute('aria-valuetext', DEFAULT_MISSING_TIME_PHRASE);
return;
}
const showRemaining = el.hasAttribute(Attributes.REMAINING);
const showDuration = el.hasAttribute(Attributes.SHOW_DURATION);
const currentTimePhrase = showRemaining
? formatAsTimePhrase(0 - (endTime - currentTime))
: formatAsTimePhrase(currentTime);
if (!showDuration) {
el.setAttribute('aria-valuetext', currentTimePhrase);
return;
}
const totalTimePhrase = formatAsTimePhrase(endTime);
const fullPhrase = `${currentTimePhrase} of ${totalTimePhrase}`;
el.setAttribute('aria-valuetext', fullPhrase);
};
/**
* @attr {boolean} remaining - Toggle on to show the remaining time instead of elapsed time.
* @attr {boolean} showduration - Toggle on to show the duration.
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
* @attr {boolean} notoggle - Set this to disable click or tap behavior that toggles between remaining and current time.
* @attr {string} mediacurrenttime - (read-only) Set to the current media time.
* @attr {string} mediaduration - (read-only) Set to the media duration.
* @attr {string} mediaseekable - (read-only) Set to the seekable time ranges.
*
* @cssproperty [--media-time-display-display = inline-flex] - `display` property of display.
* @cssproperty --media-control-hover-background - `background` of control hover state.
*/
class MediaTimeDisplay extends MediaTextDisplay {
#slot: HTMLSlotElement;
static get observedAttributes(): string[] {
return [...super.observedAttributes, ...CombinedAttributes, 'disabled'];
}
constructor() {
super();
this.#slot = this.shadowRoot.querySelector('slot');
this.#slot.innerHTML = `${formatTimesLabel(this)}`;
}
connectedCallback(): void {
const { style } = getOrInsertCSSRule(
this.shadowRoot,
':host(:hover:not([notoggle]))'
);
style.setProperty('cursor', 'pointer');
style.setProperty(
'background',
'var(--media-control-hover-background, rgba(50 50 70 / .7))'
);
if (!this.hasAttribute('disabled')) {
this.enable();
}
this.setAttribute('role', 'progressbar');
this.setAttribute('aria-label', nouns.PLAYBACK_TIME());
const keyUpHandler = (evt) => {
const { key } = evt;
if (!ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', keyUpHandler);
return;
}
this.toggleTimeDisplay();
};
this.addEventListener('keydown', (evt) => {
const { metaKey, altKey, key } = evt;
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', keyUpHandler);
return;
}
this.addEventListener('keyup', keyUpHandler);
});
this.addEventListener('click', this.toggleTimeDisplay);
super.connectedCallback();
}
toggleTimeDisplay(): void {
if (this.noToggle) {
return;
}
if (this.hasAttribute('remaining')) {
this.removeAttribute('remaining');
} else {
this.setAttribute('remaining', '');
}
}
disconnectedCallback(): void {
this.disable();
super.disconnectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (CombinedAttributes.includes(attrName)) {
this.update();
} else if (attrName === 'disabled' && newValue !== oldValue) {
if (newValue == null) {
this.enable();
} else {
this.disable();
}
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
enable(): void {
this.tabIndex = 0;
}
disable(): void {
this.tabIndex = -1;
}
// Own props
/**
* Whether to show the remaining time
*/
get remaining(): boolean {
return getBooleanAttr(this, Attributes.REMAINING);
}
set remaining(show: boolean) {
setBooleanAttr(this, Attributes.REMAINING, show);
}
/**
* Whether to show the duration
*/
get showDuration(): boolean {
return getBooleanAttr(this, Attributes.SHOW_DURATION);
}
set showDuration(show: boolean) {
setBooleanAttr(this, Attributes.SHOW_DURATION, show);
}
/**
* Disable the default behavior that toggles between current and remaining time
*/
get noToggle(): boolean {
return getBooleanAttr(this, Attributes.NO_TOGGLE);
}
set noToggle(noToggle: boolean) {
setBooleanAttr(this, Attributes.NO_TOGGLE, noToggle);
}
// Props derived from media UI attributes
/**
* Get the duration
*/
get mediaDuration(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_DURATION);
}
set mediaDuration(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, time);
}
/**
* The current time in seconds
*/
get mediaCurrentTime(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME);
}
set mediaCurrentTime(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, time);
}
/**
* Range of values that can be seeked to.
* An array of two numbers [start, end]
*/
get mediaSeekable(): [number, number] {
const seekable = this.getAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
if (!seekable) return undefined;
// Only currently supports a single, contiguous seekable range (CJP)
return seekable.split(':').map((time) => +time) as [number, number];
}
set mediaSeekable(range: [number, number]) {
if (range == null) {
this.removeAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
return;
}
this.setAttribute(MediaUIAttributes.MEDIA_SEEKABLE, range.join(':'));
}
update(): void {
const timesLabel = formatTimesLabel(this);
updateAriaValueText(this);
// Only update if it changed, timeupdate events are called a few times per second.
if (timesLabel !== this.#slot.innerHTML) {
this.#slot.innerHTML = timesLabel;
}
}
}
if (!globalThis.customElements.get('media-time-display')) {
globalThis.customElements.define('media-time-display', MediaTimeDisplay);
}
export default MediaTimeDisplay;

View File

@@ -0,0 +1,916 @@
import { globalThis, document } from './utils/server-safe-globals.js';
import { MediaChromeRange } from './media-chrome-range.js';
import './media-preview-thumbnail.js';
import './media-preview-time-display.js';
import './media-preview-chapter-display.js';
import { MediaUIEvents, MediaUIAttributes } from './constants.js';
import { nouns } from './labels/labels.js';
import { isValidNumber } from './utils/utils.js';
import { formatAsTimePhrase } from './utils/time.js';
import { isElementVisible } from './utils/element-utils.js';
import { RangeAnimation } from './utils/range-animation.js';
import {
getOrInsertCSSRule,
containsComposedNode,
closestComposedNode,
getBooleanAttr,
setBooleanAttr,
getNumericAttr,
setNumericAttr,
getStringAttr,
setStringAttr,
} from './utils/element-utils.js';
type Rects = {
box: { width: number; min: number; max: number };
range?: DOMRect;
bounds?: DOMRect;
};
const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time.';
const updateAriaValueText = (el: any): void => {
const range = el.range;
const currentTimePhrase = formatAsTimePhrase(+calcTimeFromRangeValue(el));
const totalTimePhrase = formatAsTimePhrase(+el.mediaSeekableEnd);
const fullPhrase = !(currentTimePhrase && totalTimePhrase)
? DEFAULT_MISSING_TIME_PHRASE
: `${currentTimePhrase} of ${totalTimePhrase}`;
range.setAttribute('aria-valuetext', fullPhrase);
};
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
--media-box-border-radius: 4px;
--media-box-padding-left: 10px;
--media-box-padding-right: 10px;
--media-preview-border-radius: var(--media-box-border-radius);
--media-box-arrow-offset: var(--media-box-border-radius);
--_control-background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7)));
--_preview-background: var(--media-preview-background, var(--_control-background));
${
/* 1% rail width trick was off in Safari, contain: layout seems to
prevent the horizontal overflow as well. */ ''
}
contain: layout;
}
#buffered {
background: var(--media-time-range-buffered-color, rgb(255 255 255 / .4));
position: absolute;
height: 100%;
will-change: width;
}
#preview-rail,
#current-rail {
width: 100%;
position: absolute;
left: 0;
bottom: 100%;
pointer-events: none;
will-change: transform;
}
[part~="box"] {
width: min-content;
${
/* absolute position is needed here so the box doesn't overflow the bounds */ ''
}
position: absolute;
bottom: 100%;
flex-direction: column;
align-items: center;
transform: translateX(-50%);
}
[part~="current-box"] {
display: var(--media-current-box-display, var(--media-box-display, flex));
margin: var(--media-current-box-margin, var(--media-box-margin, 0 0 5px));
visibility: hidden;
}
[part~="preview-box"] {
display: var(--media-preview-box-display, var(--media-box-display, flex));
margin: var(--media-preview-box-margin, var(--media-box-margin, 0 0 5px));
transition-property: var(--media-preview-transition-property, visibility, opacity);
transition-duration: var(--media-preview-transition-duration-out, .25s);
transition-delay: var(--media-preview-transition-delay-out, 0s);
visibility: hidden;
opacity: 0;
}
:host(:is([${MediaUIAttributes.MEDIA_PREVIEW_IMAGE}], [${
MediaUIAttributes.MEDIA_PREVIEW_TIME
}])[dragging]) [part~="preview-box"] {
transition-duration: var(--media-preview-transition-duration-in, .5s);
transition-delay: var(--media-preview-transition-delay-in, .25s);
visibility: visible;
opacity: 1;
}
@media (hover: hover) {
:host(:is([${MediaUIAttributes.MEDIA_PREVIEW_IMAGE}], [${
MediaUIAttributes.MEDIA_PREVIEW_TIME
}]):hover) [part~="preview-box"] {
transition-duration: var(--media-preview-transition-duration-in, .5s);
transition-delay: var(--media-preview-transition-delay-in, .25s);
visibility: visible;
opacity: 1;
}
}
media-preview-thumbnail,
::slotted(media-preview-thumbnail) {
visibility: hidden;
${
/* delay changing these CSS props until the preview box transition is ended */ ''
}
transition: visibility 0s .25s;
transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s));
background: var(--media-preview-thumbnail-background, var(--_preview-background));
box-shadow: var(--media-preview-thumbnail-box-shadow, 0 0 4px rgb(0 0 0 / .2));
max-width: var(--media-preview-thumbnail-max-width, 180px);
max-height: var(--media-preview-thumbnail-max-height, 160px);
min-width: var(--media-preview-thumbnail-min-width, 120px);
min-height: var(--media-preview-thumbnail-min-height, 80px);
border: var(--media-preview-thumbnail-border);
border-radius: var(--media-preview-thumbnail-border-radius,
var(--media-preview-border-radius) var(--media-preview-border-radius) 0 0);
}
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}][dragging]) media-preview-thumbnail,
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}][dragging]) ::slotted(media-preview-thumbnail) {
transition-delay: var(--media-preview-transition-delay-in, .25s);
visibility: visible;
}
@media (hover: hover) {
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]:hover) media-preview-thumbnail,
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]:hover) ::slotted(media-preview-thumbnail) {
transition-delay: var(--media-preview-transition-delay-in, .25s);
visibility: visible;
}
:host([${MediaUIAttributes.MEDIA_PREVIEW_TIME}]:hover) {
--media-time-range-hover-display: block;
}
}
media-preview-chapter-display,
::slotted(media-preview-chapter-display) {
font-size: var(--media-font-size, 13px);
line-height: 17px;
min-width: 0;
visibility: hidden;
${
/* delay changing these CSS props until the preview box transition is ended */ ''
}
transition: min-width 0s, border-radius 0s, margin 0s, padding 0s, visibility 0s;
transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s));
background: var(--media-preview-chapter-background, var(--_preview-background));
border-radius: var(--media-preview-chapter-border-radius,
var(--media-preview-border-radius) var(--media-preview-border-radius)
var(--media-preview-border-radius) var(--media-preview-border-radius));
padding: var(--media-preview-chapter-padding, 3.5px 9px);
margin: var(--media-preview-chapter-margin, 0 0 5px);
text-shadow: var(--media-preview-chapter-text-shadow, 0 0 4px rgb(0 0 0 / .75));
}
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]) media-preview-chapter-display,
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]) ::slotted(media-preview-chapter-display) {
transition-delay: var(--media-preview-transition-delay-in, .25s);
border-radius: var(--media-preview-chapter-border-radius, 0);
padding: var(--media-preview-chapter-padding, 3.5px 9px 0);
margin: var(--media-preview-chapter-margin, 0);
min-width: 100%;
}
media-preview-chapter-display[${MediaUIAttributes.MEDIA_PREVIEW_CHAPTER}],
::slotted(media-preview-chapter-display[${
MediaUIAttributes.MEDIA_PREVIEW_CHAPTER
}]) {
visibility: visible;
}
media-preview-chapter-display:not([aria-valuetext]),
::slotted(media-preview-chapter-display:not([aria-valuetext])) {
display: none;
}
media-preview-time-display,
::slotted(media-preview-time-display),
media-time-display,
::slotted(media-time-display) {
font-size: var(--media-font-size, 13px);
line-height: 17px;
min-width: 0;
${
/* delay changing these CSS props until the preview box transition is ended */ ''
}
transition: min-width 0s, border-radius 0s;
transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s));
background: var(--media-preview-time-background, var(--_preview-background));
border-radius: var(--media-preview-time-border-radius,
var(--media-preview-border-radius) var(--media-preview-border-radius)
var(--media-preview-border-radius) var(--media-preview-border-radius));
padding: var(--media-preview-time-padding, 3.5px 9px);
margin: var(--media-preview-time-margin, 0);
text-shadow: var(--media-preview-time-text-shadow, 0 0 4px rgb(0 0 0 / .75));
transform: translateX(min(
max(calc(50% - var(--_box-width) / 2),
calc(var(--_box-shift, 0))),
calc(var(--_box-width) / 2 - 50%)
));
}
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]) media-preview-time-display,
:host([${
MediaUIAttributes.MEDIA_PREVIEW_IMAGE
}]) ::slotted(media-preview-time-display) {
transition-delay: var(--media-preview-transition-delay-in, .25s);
border-radius: var(--media-preview-time-border-radius,
0 0 var(--media-preview-border-radius) var(--media-preview-border-radius));
min-width: 100%;
}
:host([${MediaUIAttributes.MEDIA_PREVIEW_TIME}]:hover) {
--media-time-range-hover-display: block;
}
[part~="arrow"],
::slotted([part~="arrow"]) {
display: var(--media-box-arrow-display, inline-block);
transform: translateX(min(
max(calc(50% - var(--_box-width) / 2 + var(--media-box-arrow-offset)),
calc(var(--_box-shift, 0))),
calc(var(--_box-width) / 2 - 50% - var(--media-box-arrow-offset))
));
${/* border-color has to come before border-top-color! */ ''}
border-color: transparent;
border-top-color: var(--media-box-arrow-background, var(--_control-background));
border-width: var(--media-box-arrow-border-width,
var(--media-box-arrow-height, 5px) var(--media-box-arrow-width, 6px) 0);
border-style: solid;
justify-content: center;
height: 0;
}
</style>
<div id="preview-rail">
<slot name="preview" part="box preview-box">
<media-preview-thumbnail></media-preview-thumbnail>
<media-preview-chapter-display></media-preview-chapter-display>
<media-preview-time-display></media-preview-time-display>
<slot name="preview-arrow"><div part="arrow"></div></slot>
</slot>
</div>
<div id="current-rail">
<slot name="current" part="box current-box">
${
/* Example: add the current time w/ arrow to the playhead
<media-time-display slot="current"></media-time-display>
<div part="arrow" slot="current"></div> */ ''
}
</slot>
</div>
`;
const calcRangeValueFromTime = (
el: any,
time: number = el.mediaCurrentTime
): number => {
const startTime = Number.isFinite(el.mediaSeekableStart)
? el.mediaSeekableStart
: 0;
// Prefer `mediaDuration` when available and finite.
const endTime = Number.isFinite(el.mediaDuration)
? el.mediaDuration
: el.mediaSeekableEnd;
if (Number.isNaN(endTime)) return 0;
const value = (time - startTime) / (endTime - startTime);
return Math.max(0, Math.min(value, 1));
};
const calcTimeFromRangeValue = (
el: any,
value: number = el.range.valueAsNumber
): number => {
const startTime = Number.isFinite(el.mediaSeekableStart)
? el.mediaSeekableStart
: 0;
// Prefer `mediaDuration` when available and finite.
const endTime = Number.isFinite(el.mediaDuration)
? el.mediaDuration
: el.mediaSeekableEnd;
if (Number.isNaN(endTime)) return 0;
return value * (endTime - startTime) + startTime;
};
/**
* @slot preview - An element that slides along the timeline to the position of the pointer hovering.
* @slot preview-arrow - An arrow element that slides along the timeline to the position of the pointer hovering.
* @slot current - An element that slides along the timeline to the position of the current time.
*
* @attr {string} mediabuffered - (read-only) Set to the buffered time ranges.
* @attr {string} mediaplaybackrate - (read-only) Set to the media playback rate.
* @attr {string} mediaduration - (read-only) Set to the media duration.
* @attr {string} mediaseekable - (read-only) Set to the seekable time ranges.
* @attr {boolean} mediapaused - (read-only) Present if the media is paused.
* @attr {boolean} medialoading - (read-only) Present if the media is loading.
* @attr {string} mediacurrenttime - (read-only) Set to the current media time.
* @attr {string} mediapreviewimage - (read-only) Set to the timeline preview image URL.
* @attr {string} mediapreviewtime - (read-only) Set to the timeline preview time.
*
* @csspart buffered - A CSS part that selects the buffered bar element.
* @csspart box - A CSS part that selects both the preview and current box elements.
* @csspart preview-box - A CSS part that selects the preview box element.
* @csspart current-box - A CSS part that selects the current box element.
* @csspart arrow - A CSS part that selects the arrow element.
*
* @cssproperty [--media-time-range-display = inline-block] - `display` property of range.
*
* @cssproperty --media-preview-transition-property - `transition-property` of range hover preview.
* @cssproperty --media-preview-transition-duration-out - `transition-duration` out of range hover preview.
* @cssproperty --media-preview-transition-delay-out - `transition-delay` out of range hover preview.
* @cssproperty --media-preview-transition-duration-in - `transition-duration` in of range hover preview.
* @cssproperty --media-preview-transition-delay-in - `transition-delay` in of range hover preview.
*
* @cssproperty --media-preview-thumbnail-background - `background` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-box-shadow - `box-shadow` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-max-width - `max-width` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-max-height - `max-height` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-min-width - `min-width` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-min-height - `min-height` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-border-radius - `border-radius` of range preview thumbnail.
* @cssproperty --media-preview-thumbnail-border - `border` of range preview thumbnail.
*
* @cssproperty --media-preview-chapter-background - `background` of range preview chapter display.
* @cssproperty --media-preview-chapter-border-radius - `border-radius` of range preview chapter display.
* @cssproperty --media-preview-chapter-padding - `padding` of range preview chapter display.
* @cssproperty --media-preview-chapter-margin - `margin` of range preview chapter display.
* @cssproperty --media-preview-chapter-text-shadow - `text-shadow` of range preview chapter display.
*
* @cssproperty --media-preview-time-background - `background` of range preview time display.
* @cssproperty --media-preview-time-border-radius - `border-radius` of range preview time display.
* @cssproperty --media-preview-time-padding - `padding` of range preview time display.
* @cssproperty --media-preview-time-margin - `margin` of range preview time display.
* @cssproperty --media-preview-time-text-shadow - `text-shadow` of range preview time display.
*
* @cssproperty --media-box-display - `display` of range box.
* @cssproperty --media-box-margin - `margin` of range box.
* @cssproperty --media-box-padding-left - `padding-left` of range box.
* @cssproperty --media-box-padding-right - `padding-right` of range box.
* @cssproperty --media-box-border-radius - `border-radius` of range box.
*
* @cssproperty --media-preview-box-display - `display` of range preview box.
* @cssproperty --media-preview-box-margin - `margin` of range preview box.
*
* @cssproperty --media-current-box-display - `display` of range current box.
* @cssproperty --media-current-box-margin - `margin` of range current box.
*
* @cssproperty --media-box-arrow-display - `display` of range box arrow.
* @cssproperty --media-box-arrow-background - `border-top-color` of range box arrow.
* @cssproperty --media-box-arrow-border-width - `border-width` of range box arrow.
* @cssproperty --media-box-arrow-height - `height` of range box arrow.
* @cssproperty --media-box-arrow-width - `width` of range box arrow.
* @cssproperty --media-box-arrow-offset - `translateX` offset of range box arrow.
*/
class MediaTimeRange extends MediaChromeRange {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PAUSED,
MediaUIAttributes.MEDIA_DURATION,
MediaUIAttributes.MEDIA_SEEKABLE,
MediaUIAttributes.MEDIA_CURRENT_TIME,
MediaUIAttributes.MEDIA_PREVIEW_IMAGE,
MediaUIAttributes.MEDIA_PREVIEW_TIME,
MediaUIAttributes.MEDIA_PREVIEW_CHAPTER,
MediaUIAttributes.MEDIA_BUFFERED,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
MediaUIAttributes.MEDIA_LOADING,
MediaUIAttributes.MEDIA_ENDED,
];
}
#rootNode;
#animation;
#boxes;
#previewTime: number;
#previewBox: HTMLElement;
#currentBox: HTMLElement;
#boxPaddingLeft: number;
#boxPaddingRight: number;
#mediaChaptersCues;
constructor() {
super();
this.container.appendChild(template.content.cloneNode(true));
const track = this.shadowRoot.querySelector('#track');
track.insertAdjacentHTML('afterbegin', '<div id="buffered" part="buffered"></div>');
this.#boxes = this.shadowRoot.querySelectorAll('[part~="box"]');
this.#previewBox = this.shadowRoot.querySelector('[part~="preview-box"]');
this.#currentBox = this.shadowRoot.querySelector('[part~="current-box"]');
const computedStyle = getComputedStyle(this);
this.#boxPaddingLeft = parseInt(
computedStyle.getPropertyValue('--media-box-padding-left')
);
this.#boxPaddingRight = parseInt(
computedStyle.getPropertyValue('--media-box-padding-right')
);
this.#animation = new RangeAnimation(this.range, this.#updateRange, 60);
}
connectedCallback(): void {
super.connectedCallback();
this.range.setAttribute('aria-label', nouns.SEEK());
this.#toggleRangeAnimation();
// NOTE: Adding an event listener to an ancestor here.
this.#rootNode = this.getRootNode();
this.#rootNode?.addEventListener('transitionstart', this);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.#toggleRangeAnimation();
this.#rootNode?.removeEventListener('transitionstart', this);
this.#rootNode = null;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (oldValue == newValue) return;
if (
attrName === MediaUIAttributes.MEDIA_CURRENT_TIME ||
attrName === MediaUIAttributes.MEDIA_PAUSED ||
attrName === MediaUIAttributes.MEDIA_ENDED ||
attrName === MediaUIAttributes.MEDIA_LOADING ||
attrName === MediaUIAttributes.MEDIA_DURATION ||
attrName === MediaUIAttributes.MEDIA_SEEKABLE
) {
this.#animation.update({
start: calcRangeValueFromTime(this),
duration: this.mediaSeekableEnd - this.mediaSeekableStart,
playbackRate: this.mediaPlaybackRate,
});
this.#toggleRangeAnimation();
updateAriaValueText(this);
} else if (attrName === MediaUIAttributes.MEDIA_BUFFERED) {
this.updateBufferedBar();
}
if (
attrName === MediaUIAttributes.MEDIA_DURATION ||
attrName === MediaUIAttributes.MEDIA_SEEKABLE
) {
this.mediaChaptersCues = this.#mediaChaptersCues;
this.updateBar();
}
}
#toggleRangeAnimation(): void {
if (this.#shouldRangeAnimate()) {
this.#animation.start();
} else {
this.#animation.stop();
}
}
#shouldRangeAnimate(): boolean {
return (
this.isConnected &&
!this.mediaPaused &&
!this.mediaLoading &&
!this.mediaEnded &&
this.mediaSeekableEnd > 0 &&
isElementVisible(this)
);
}
#updateRange = (value: number): void => {
if (this.dragging) return;
if (isValidNumber(value)) {
this.range.valueAsNumber = value;
}
this.updateBar();
};
get mediaChaptersCues(): any[] {
return this.#mediaChaptersCues;
}
set mediaChaptersCues(value: any[]) {
this.#mediaChaptersCues = value;
this.updateSegments(
this.#mediaChaptersCues?.map((c) => ({
start: calcRangeValueFromTime(this, c.startTime),
end: calcRangeValueFromTime(this, c.endTime),
}))
);
}
/**
* Is the media paused
*/
get mediaPaused(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED);
}
set mediaPaused(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value);
}
/**
* Is the media loading
*/
get mediaLoading(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING);
}
set mediaLoading(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING, value);
}
/**
*
*/
get mediaDuration(): number | undefined {
return getNumericAttr(this, MediaUIAttributes.MEDIA_DURATION);
}
set mediaDuration(value: number | undefined) {
setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, value);
}
/**
*
*/
get mediaCurrentTime(): number | undefined {
return getNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME);
}
set mediaCurrentTime(value: number | undefined) {
setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, value);
}
/**
*
*/
get mediaPlaybackRate(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, 1);
}
set mediaPlaybackRate(value: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, value);
}
/**
* An array of ranges, each range being an array of two numbers.
* e.g. [[1, 2], [3, 4]]
*/
get mediaBuffered(): number[][] {
const buffered = this.getAttribute(MediaUIAttributes.MEDIA_BUFFERED);
if (!buffered) return [];
return buffered
.split(' ')
.map((timePair) => timePair.split(':').map((timeStr) => +timeStr));
}
set mediaBuffered(list: number[][]) {
if (!list) {
this.removeAttribute(MediaUIAttributes.MEDIA_BUFFERED);
return;
}
const strVal = list.map((tuple) => tuple.join(':')).join(' ');
this.setAttribute(MediaUIAttributes.MEDIA_BUFFERED, strVal);
}
/**
* Range of values that can be seeked to
* An array of two numbers [start, end]
*/
get mediaSeekable(): number[] | undefined {
const seekable = this.getAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
if (!seekable) return undefined;
// Only currently supports a single, contiguous seekable range (CJP)
return seekable.split(':').map((time) => +time);
}
set mediaSeekable(range: number[] | undefined) {
if (range == null) {
this.removeAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
return;
}
this.setAttribute(MediaUIAttributes.MEDIA_SEEKABLE, range.join(':'));
}
/**
*
*/
get mediaSeekableEnd(): number | undefined {
const [, end = this.mediaDuration] = this.mediaSeekable ?? [];
return end;
}
get mediaSeekableStart(): number {
const [start = 0] = this.mediaSeekable ?? [];
return start;
}
/**
* The url of the preview image
*/
get mediaPreviewImage(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE);
}
set mediaPreviewImage(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE, value);
}
/**
*
*/
get mediaPreviewTime(): number | undefined {
return getNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME);
}
set mediaPreviewTime(value: number | undefined) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME, value);
}
/**
*
*/
get mediaEnded(): boolean | undefined {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_ENDED);
}
set mediaEnded(value: boolean | undefined) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_ENDED, value);
}
/* Add a buffered progress bar */
updateBar(): void {
super.updateBar();
this.updateBufferedBar();
this.updateCurrentBox();
}
updateBufferedBar(): void {
const buffered = this.mediaBuffered;
if (!buffered.length) {
return;
}
// Find the buffered range that "contains" the current time and get its end.
// If none, just assume the start of the media timeline for
// visualization purposes.
let relativeBufferedEnd;
if (!this.mediaEnded) {
const currentTime = this.mediaCurrentTime;
const [, bufferedEnd = this.mediaSeekableStart] =
buffered.find(
([start, end]) => start <= currentTime && currentTime <= end
) ?? [];
relativeBufferedEnd = calcRangeValueFromTime(this, bufferedEnd);
} else {
// If we've ended, there may be some discrepancies between seekable end, duration, and current time.
// In this case, just presume `relativeBufferedEnd` is the maximum possible value for visualization
// purposes (CJP.)
relativeBufferedEnd = 1;
}
const { style } = getOrInsertCSSRule(this.shadowRoot, '#buffered');
style.setProperty('width', `${relativeBufferedEnd * 100}%`);
}
updateCurrentBox(): void {
// If there are no elements in the current box no need for expensive style updates.
const currentSlot: HTMLSlotElement = this.shadowRoot.querySelector(
'slot[name="current"]'
);
if (!currentSlot.assignedElements().length) return;
const currentRailRule = getOrInsertCSSRule(
this.shadowRoot,
'#current-rail'
);
const currentBoxRule = getOrInsertCSSRule(
this.shadowRoot,
'[part~="current-box"]'
);
const rects = this.#getElementRects(this.#currentBox);
const boxPos = this.#getBoxPosition(rects, this.range.valueAsNumber);
const boxShift = this.#getBoxShiftPosition(rects, this.range.valueAsNumber);
currentRailRule.style.transform = `translateX(${boxPos})`;
currentRailRule.style.setProperty('--_range-width', `${rects.range.width}`);
currentBoxRule.style.setProperty('--_box-shift', `${boxShift}`);
currentBoxRule.style.setProperty('--_box-width', `${rects.box.width}px`);
currentBoxRule.style.setProperty('visibility', 'initial');
}
#getElementRects(box: HTMLElement) {
// Get the element that enforces the bounds for the time range boxes.
const bounds =
(this.getAttribute('bounds')
? closestComposedNode(this, `#${this.getAttribute('bounds')}`)
: this.parentElement) ?? this;
const boundsRect = bounds.getBoundingClientRect();
const rangeRect = this.range.getBoundingClientRect();
// Use offset dimensions to include borders.
const width = box.offsetWidth;
const min = -(rangeRect.left - boundsRect.left - width / 2);
const max = boundsRect.right - rangeRect.left - width / 2;
return {
box: { width, min, max },
bounds: boundsRect,
range: rangeRect,
};
}
/**
* Get the position, max and min for the box in percentage.
* It's important this is in percentage so when the player is resized
* the box will move accordingly.
*/
#getBoxPosition(rects: Rects, ratio: number): string {
let position = `${ratio * 100}%`;
const { width, min, max } = rects.box;
if (!width) return position;
if (!Number.isNaN(min)) {
const pad = `var(--media-box-padding-left)`;
const minPos = `calc(1 / var(--_range-width) * 100 * ${min}% + ${pad})`;
position = `max(${minPos}, ${position})`;
}
if (!Number.isNaN(max)) {
const pad = `var(--media-box-padding-right)`;
const maxPos = `calc(1 / var(--_range-width) * 100 * ${max}% - ${pad})`;
position = `min(${position}, ${maxPos})`;
}
return position;
}
#getBoxShiftPosition(rects: Rects, ratio: number) {
const { width, min, max } = rects.box;
const pointerX = ratio * rects.range.width;
if (pointerX < min + this.#boxPaddingLeft) {
const offset =
rects.range.left - rects.bounds.left - this.#boxPaddingLeft;
return `${pointerX - width / 2 + offset}px`;
}
if (pointerX > max - this.#boxPaddingRight) {
const offset =
rects.bounds.right - rects.range.right - this.#boxPaddingRight;
return `${pointerX + width / 2 - offset - rects.range.width}px`;
}
return 0;
}
handleEvent(evt: Event | MouseEvent): void {
super.handleEvent(evt);
switch (evt.type) {
case 'input':
this.#seekRequest();
break;
case 'pointermove':
this.#handlePointerMove(evt as MouseEvent);
break;
case 'pointerup':
case 'pointerleave':
this.#previewRequest(null);
break;
case 'transitionstart':
if (containsComposedNode(evt.target as any, this)) {
// Wait a tick to be sure the transition has started. Required for Safari.
setTimeout(() => this.#toggleRangeAnimation(), 0);
}
break;
}
}
#handlePointerMove(evt: MouseEvent): void {
// @ts-ignore
const isOverBoxes = [...this.#boxes].some((b) =>
evt.composedPath().includes(b)
);
if (!this.dragging && (isOverBoxes || !evt.composedPath().includes(this))) {
this.#previewRequest(null);
return;
}
const duration = this.mediaSeekableEnd;
// If no duration we can't calculate which time to show
if (!duration) return;
const previewRailRule = getOrInsertCSSRule(
this.shadowRoot,
'#preview-rail'
);
const previewBoxRule = getOrInsertCSSRule(
this.shadowRoot,
'[part~="preview-box"]'
);
const rects = this.#getElementRects(this.#previewBox);
let pointerRatio = (evt.clientX - rects.range.left) / rects.range.width;
pointerRatio = Math.max(0, Math.min(1, pointerRatio));
const boxPos = this.#getBoxPosition(rects, pointerRatio);
const boxShift = this.#getBoxShiftPosition(rects, pointerRatio);
previewRailRule.style.transform = `translateX(${boxPos})`;
previewRailRule.style.setProperty('--_range-width', `${rects.range.width}`);
previewBoxRule.style.setProperty('--_box-shift', `${boxShift}`);
previewBoxRule.style.setProperty('--_box-width', `${rects.box.width}px`);
// At least require a 1s difference before requesting a new preview thumbnail,
// unless it's at the beginning or end of the timeline.
const diff =
Math.round(this.#previewTime) - Math.round(pointerRatio * duration);
if (Math.abs(diff) < 1 && pointerRatio > 0.01 && pointerRatio < 0.99)
return;
this.#previewTime = pointerRatio * duration;
this.#previewRequest(this.#previewTime);
}
#previewRequest(detail): void {
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_PREVIEW_REQUEST, {
composed: true,
bubbles: true,
detail,
})
);
}
#seekRequest(): void {
// Cancel progress bar refreshing when seeking.
this.#animation.stop();
const detail = calcTimeFromRangeValue(this);
this.dispatchEvent(
new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
})
);
}
}
if (!globalThis.customElements.get('media-time-range')) {
globalThis.customElements.define('media-time-range', MediaTimeRange);
}
export default MediaTimeRange;

View File

@@ -0,0 +1,286 @@
import {
closestComposedNode,
getMediaController,
getStringAttr,
isElementVisible,
setStringAttr,
} from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
export const Attributes = {
PLACEMENT: 'placement',
BOUNDS: 'bounds',
};
export type TooltipPlacement = 'top' | 'right' | 'bottom' | 'left' | 'none';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
--_tooltip-background-color: var(--media-tooltip-background-color, var(--media-secondary-color, rgba(20, 20, 30, .7)));
--_tooltip-background: var(--media-tooltip-background, var(--_tooltip-background-color));
--_tooltip-arrow-half-width: calc(var(--media-tooltip-arrow-width, 12px) / 2);
--_tooltip-arrow-height: var(--media-tooltip-arrow-height, 5px);
--_tooltip-arrow-background: var(--media-tooltip-arrow-color, var(--_tooltip-background-color));
position: relative;
pointer-events: none;
display: var(--media-tooltip-display, inline-flex);
justify-content: center;
align-items: center;
box-sizing: border-box;
z-index: var(--media-tooltip-z-index, 1);
background: var(--_tooltip-background);
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
font: var(--media-font,
var(--media-font-weight, 400)
var(--media-font-size, 13px) /
var(--media-text-content-height, var(--media-control-height, 18px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
padding: var(--media-tooltip-padding, .35em .7em);
border: var(--media-tooltip-border, none);
border-radius: var(--media-tooltip-border-radius, 5px);
filter: var(--media-tooltip-filter, drop-shadow(0 0 4px rgba(0, 0, 0, .2)));
white-space: var(--media-tooltip-white-space, nowrap);
}
:host([hidden]) {
display: none;
}
img, svg {
display: inline-block;
}
#arrow {
position: absolute;
width: 0px;
height: 0px;
border-style: solid;
display: var(--media-tooltip-arrow-display, block);
}
:host(:not([placement])),
:host([placement="top"]) {
position: absolute;
bottom: calc(100% + var(--media-tooltip-distance, 12px));
left: 50%;
transform: translate(calc(-50% - var(--media-tooltip-offset-x, 0px)), 0);
}
:host(:not([placement])) #arrow,
:host([placement="top"]) #arrow {
top: 100%;
left: 50%;
border-width: var(--_tooltip-arrow-height) var(--_tooltip-arrow-half-width) 0 var(--_tooltip-arrow-half-width);
border-color: var(--_tooltip-arrow-background) transparent transparent transparent;
transform: translate(calc(-50% + var(--media-tooltip-offset-x, 0px)), 0);
}
:host([placement="right"]) {
position: absolute;
left: calc(100% + var(--media-tooltip-distance, 12px));
top: 50%;
transform: translate(0, -50%);
}
:host([placement="right"]) #arrow {
top: 50%;
right: 100%;
border-width: var(--_tooltip-arrow-half-width) var(--_tooltip-arrow-height) var(--_tooltip-arrow-half-width) 0;
border-color: transparent var(--_tooltip-arrow-background) transparent transparent;
transform: translate(0, -50%);
}
:host([placement="bottom"]) {
position: absolute;
top: calc(100% + var(--media-tooltip-distance, 12px));
left: 50%;
transform: translate(calc(-50% - var(--media-tooltip-offset-x, 0px)), 0);
}
:host([placement="bottom"]) #arrow {
bottom: 100%;
left: 50%;
border-width: 0 var(--_tooltip-arrow-half-width) var(--_tooltip-arrow-height) var(--_tooltip-arrow-half-width);
border-color: transparent transparent var(--_tooltip-arrow-background) transparent;
transform: translate(calc(-50% + var(--media-tooltip-offset-x, 0px)), 0);
}
:host([placement="left"]) {
position: absolute;
right: calc(100% + var(--media-tooltip-distance, 12px));
top: 50%;
transform: translate(0, -50%);
}
:host([placement="left"]) #arrow {
top: 50%;
left: 100%;
border-width: var(--_tooltip-arrow-half-width) 0 var(--_tooltip-arrow-half-width) var(--_tooltip-arrow-height);
border-color: transparent transparent transparent var(--_tooltip-arrow-background);
transform: translate(0, -50%);
}
:host([placement="none"]) #arrow {
display: none;
}
</style>
<slot></slot>
<div id="arrow"></div>
`;
/**
* @extends {HTMLElement}
*
* @attr {('top'|'right'|'bottom'|'left'|'none')} placement - The placement of the tooltip, defaults to "top"
* @attr {string} bounds - ID for the containing element (one of it's parents) that should constrain the tooltips horizontal position.
*
* @cssproperty --media-primary-color - Default color of text.
* @cssproperty --media-secondary-color - Default color of tooltip background.
* @cssproperty --media-text-color - `color` of tooltip text.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of button text.
*
* @cssproperty --media-tooltip-border - 'border' of tooltip
* @cssproperty --media-tooltip-background-color - Background color of tooltip and arrow, unless individually overidden
* @cssproperty --media-tooltip-background - `background` of tooltip, ignoring the arrow
* @cssproperty --media-tooltip-display - `display` of tooltip
* @cssproperty --media-tooltip-z-index - `z-index` of tooltip
* @cssproperty --media-tooltip-padding - `padding` of tooltip
* @cssproperty --media-tooltip-border-radius - `border-radius` of tooltip
* @cssproperty --media-tooltip-filter - `filter` property of tooltip, for drop-shadow
* @cssproperty --media-tooltip-white-space - `white-space` property of tooltip
* @cssproperty --media-tooltip-arrow-display - `display` property of tooltip arrow
* @cssproperty --media-tooltip-arrow-width - Arrow width
* @cssproperty --media-tooltip-arrow-height - Arrow height
* @cssproperty --media-tooltip-arrow-color - Arrow color
*/
class MediaTooltip extends globalThis.HTMLElement {
static get observedAttributes(): string[] {
return [Attributes.PLACEMENT, Attributes.BOUNDS];
}
arrowEl: HTMLElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
this.arrowEl = this.shadowRoot.querySelector('#arrow');
// Check if the placement prop has been set before the element was
// defined / upgraded. Without this, placement might be permanently overriden
// on the target element.
// see: https://nolanlawson.com/2021/08/03/handling-properties-in-custom-element-upgrades/
if (Object.prototype.hasOwnProperty.call(this, 'placement')) {
const placement = this.placement;
delete this.placement;
this.placement = placement;
}
}
// Adjusts tooltip position relative to the closest specified container
// such that it doesn't spill out of the left or right sides. Only applies
// to 'top' and 'bottom' placed tooltips.
updateXOffset = () => {
// If the tooltip is hidden don't offset the tooltip because it could be
// positioned offscreen causing scrollbars to appear.
if (!isElementVisible(this, { checkOpacity: false, checkVisibilityCSS: false })) return;
const placement = this.placement;
// we don't offset against tooltips coming out of left and right sides
if (placement === 'left' || placement === 'right') {
// could have been offset before switching to a new position
this.style.removeProperty('--media-tooltip-offset-x');
return;
}
// We need to calculate the difference (diff) between the left edge of the
// tooltip compared to the left edge of the container element, to see if it
// bleeds out (and the same for the right edges).
// If they do, then we apply the diff as an offset to get it back within bounds
// + any extra margin specified to create some buffer space, so it looks better.
// e.g. it's 20px out of bounds, we nudge it 20px back in + margin
const tooltipStyle = getComputedStyle(this);
const containingEl =
closestComposedNode(this, '#' + this.bounds) ?? getMediaController(this);
if (!containingEl) return;
const { x: containerX, width: containerWidth } =
containingEl.getBoundingClientRect();
const { x: tooltipX, width: tooltipWidth } = this.getBoundingClientRect();
const tooltipRight = tooltipX + tooltipWidth;
const containerRight = containerX + containerWidth;
const offsetXVal = tooltipStyle.getPropertyValue(
'--media-tooltip-offset-x'
);
const currOffsetX = offsetXVal
? parseFloat(offsetXVal.replace('px', ''))
: 0;
const marginVal = tooltipStyle.getPropertyValue(
'--media-tooltip-container-margin'
);
const currMargin = marginVal ? parseFloat(marginVal.replace('px', '')) : 0;
// We might have already offset the tooltip previously so we remove it's
// current offset from our calculations, because we need to know if it goes
// outside the boundary if we weren't already offsetting it
// We also add on any additional container margin specified. Depending on
// if we're adjusting the element leftwards or rightwards, we need either a
// positive or negative offset
const leftDiff = tooltipX - containerX + currOffsetX - currMargin;
const rightDiff = tooltipRight - containerRight + currOffsetX + currMargin;
// out of left bounds
if (leftDiff < 0) {
this.style.setProperty('--media-tooltip-offset-x', `${leftDiff}px`);
return;
}
// out of right bounds
if (rightDiff > 0) {
this.style.setProperty('--media-tooltip-offset-x', `${rightDiff}px`);
return;
}
// no spilling out
this.style.removeProperty('--media-tooltip-offset-x');
};
/**
* Get or set tooltip placement
*/
get placement(): TooltipPlacement | undefined {
return getStringAttr(this, Attributes.PLACEMENT);
}
set placement(value: TooltipPlacement | undefined) {
setStringAttr(this, Attributes.PLACEMENT, value);
}
/**
* Get or set tooltip container ID selector that will constrain the tooltips
* horizontal position.
*/
get bounds(): string | undefined {
return getStringAttr(this, Attributes.BOUNDS);
}
set bounds(value: string | undefined) {
setStringAttr(this, Attributes.BOUNDS, value);
}
}
if (!globalThis.customElements.get('media-tooltip')) {
globalThis.customElements.define('media-tooltip', MediaTooltip);
}
export default MediaTooltip;

View File

@@ -0,0 +1,120 @@
import { globalThis } from './utils/server-safe-globals.js';
import { MediaChromeRange } from './media-chrome-range.js';
import { MediaUIAttributes, MediaUIEvents } from './constants.js';
import { nouns } from './labels/labels.js';
import {
getBooleanAttr,
getNumericAttr,
getStringAttr,
setBooleanAttr,
setNumericAttr,
setStringAttr,
} from './utils/element-utils.js';
const DEFAULT_VOLUME = 1;
const toVolume = (el: any): number => {
if (el.mediaMuted) return 0;
return el.mediaVolume;
};
const formatAsPercentString = (value: number): string => `${Math.round(value * 100)}%`;
/**
* @attr {string} mediavolume - (read-only) Set to the media volume.
* @attr {boolean} mediamuted - (read-only) Set to the media muted state.
* @attr {string} mediavolumeunavailable - (read-only) Set if changing volume is unavailable.
*
* @cssproperty [--media-volume-range-display = inline-block] - `display` property of range.
*/
class MediaVolumeRange extends MediaChromeRange {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_VOLUME,
MediaUIAttributes.MEDIA_MUTED,
MediaUIAttributes.MEDIA_VOLUME_UNAVAILABLE,
];
}
constructor() {
super();
this.range.addEventListener('input', () => {
const detail = this.range.value;
const evt = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_VOLUME_REQUEST,
{
composed: true,
bubbles: true,
detail,
}
);
this.dispatchEvent(evt);
});
}
connectedCallback(): void {
super.connectedCallback();
this.range.setAttribute('aria-label', nouns.VOLUME());
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (
attrName === MediaUIAttributes.MEDIA_VOLUME ||
attrName === MediaUIAttributes.MEDIA_MUTED
) {
this.range.valueAsNumber = toVolume(this);
this.range.setAttribute(
'aria-valuetext',
formatAsPercentString(this.range.valueAsNumber)
);
this.updateBar();
}
}
/**
*
*/
get mediaVolume(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_VOLUME, DEFAULT_VOLUME);
}
set mediaVolume(value: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_VOLUME, value);
}
/**
* Is the media currently muted
*/
get mediaMuted(): boolean {
return getBooleanAttr(this, MediaUIAttributes.MEDIA_MUTED);
}
set mediaMuted(value: boolean) {
setBooleanAttr(this, MediaUIAttributes.MEDIA_MUTED, value);
}
/**
* The volume unavailability state
*/
get mediaVolumeUnavailable(): string | undefined {
return getStringAttr(this, MediaUIAttributes.MEDIA_VOLUME_UNAVAILABLE);
}
set mediaVolumeUnavailable(value: string | undefined) {
setStringAttr(this, MediaUIAttributes.MEDIA_VOLUME_UNAVAILABLE, value);
}
}
if (!globalThis.customElements.get('media-volume-range')) {
globalThis.customElements.define('media-volume-range', MediaVolumeRange);
}
export default MediaVolumeRange;

13
server/node_modules/media-chrome/src/js/menu/index.ts generated vendored Normal file
View File

@@ -0,0 +1,13 @@
export { MediaChromeMenu } from './media-chrome-menu.js';
export { MediaChromeMenuItem } from './media-chrome-menu-item.js';
export { MediaSettingsMenu } from './media-settings-menu.js';
export { MediaSettingsMenuItem } from './media-settings-menu-item.js';
export { MediaSettingsMenuButton } from './media-settings-menu-button.js';
export { MediaAudioTrackMenu } from './media-audio-track-menu.js';
export { MediaAudioTrackMenuButton } from './media-audio-track-menu-button.js';
export { MediaCaptionsMenu } from './media-captions-menu.js';
export { MediaCaptionsMenuButton } from './media-captions-menu-button.js';
export { MediaPlaybackRateMenu } from './media-playback-rate-menu.js';
export { MediaPlaybackRateMenuButton } from './media-playback-rate-menu-button.js';
export { MediaRenditionMenu } from './media-rendition-menu.js';
export { MediaRenditionMenuButton } from './media-rendition-menu-button.js';

View File

@@ -0,0 +1,90 @@
import { MediaUIAttributes } from '../constants.js';
import { MediaChromeMenuButton } from './media-chrome-menu-button.js';
import { globalThis, document } from '../utils/server-safe-globals.js';
import { nouns, tooltipLabels } from '../labels/labels.js';
import {
getStringAttr,
setStringAttr,
getMediaController,
} from '../utils/element-utils.js';
const audioTrackIcon = /*html*/ `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M11 17H9.5V7H11v10Zm-3-3H6.5v-4H8v4Zm6-5h-1.5v6H14V9Zm3 7h-1.5V8H17v8Z"/>
<path d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"/>
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([aria-expanded="true"]) slot[name=tooltip] {
display: none;
}
</style>
<slot name="icon">${audioTrackIcon}</slot>
`;
/**
* @attr {string} mediaaudiotrackenabled - (read-only) Set to the selected audio track id.
* @attr {(unavailable|unsupported)} mediaaudiotrackunavailable - (read-only) Set if audio track selection is unavailable.
*
* @cssproperty [--media-audio-track-menu-button-display = inline-flex] - `display` property of button.
*/
class MediaAudioTrackMenuButton extends MediaChromeMenuButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED,
MediaUIAttributes.MEDIA_AUDIO_TRACK_UNAVAILABLE,
];
}
constructor() {
super({ slotTemplate, tooltipContent: tooltipLabels.AUDIO_TRACK_MENU });
}
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-label', nouns.AUDIO_TRACKS());
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
* @return {HTMLElement | null}
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget != undefined) return super.invokeTargetElement;
return getMediaController(this)?.querySelector('media-audio-track-menu');
}
/**
* Get enabled audio track id.
* @return {string}
*/
get mediaAudioTrackEnabled(): string {
return (
getStringAttr(this, MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED) ?? ''
);
}
set mediaAudioTrackEnabled(id: string) {
setStringAttr(this, MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED, id);
}
}
if (!globalThis.customElements.get('media-audio-track-menu-button')) {
globalThis.customElements.define(
'media-audio-track-menu-button',
MediaAudioTrackMenuButton
);
}
export { MediaAudioTrackMenuButton };
export default MediaAudioTrackMenuButton;

View File

@@ -0,0 +1,147 @@
import { globalThis } from '../utils/server-safe-globals.js';
import { MediaUIAttributes, MediaUIEvents } from '../constants.js';
import { parseAudioTrackList } from '../utils/utils.js';
import {
MediaChromeMenu,
createMenuItem,
createIndicator,
} from './media-chrome-menu.js';
import {
getStringAttr,
setStringAttr,
getMediaController,
} from '../utils/element-utils.js';
import { TextTrackLike } from '../utils/TextTrackLike.js';
/**
* @extends {MediaChromeMenu}
*
* @slot - Default slotted elements.node
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
*
* @attr {string} mediaaudiotrackenabled - (read-only) Set to the enabled audio track.
* @attr {string} mediaaudiotracklist - (read-only) Set to the audio track list.
*/
class MediaAudioTrackMenu extends MediaChromeMenu {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_AUDIO_TRACK_LIST,
MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED,
MediaUIAttributes.MEDIA_AUDIO_TRACK_UNAVAILABLE,
];
}
#audioTrackList: TextTrackLike[] = [];
#prevState: string | undefined;
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (
attrName === MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED &&
oldValue !== newValue
) {
this.value = newValue;
} else if (
attrName === MediaUIAttributes.MEDIA_AUDIO_TRACK_LIST &&
oldValue !== newValue
) {
this.#audioTrackList = parseAudioTrackList(newValue ?? '');
this.#render();
}
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('change', this.#onChange);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('change', this.#onChange);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor !== 'auto') return super.anchorElement;
return getMediaController(this)?.querySelector<HTMLElement>(
'media-audio-track-menu-button'
);
}
get mediaAudioTrackList(): TextTrackLike[] {
return this.#audioTrackList;
}
set mediaAudioTrackList(list: TextTrackLike[]) {
this.#audioTrackList = list;
this.#render();
}
/**
* Get enabled audio track id.
*/
get mediaAudioTrackEnabled(): string {
return (
getStringAttr(this, MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED) ?? ''
);
}
set mediaAudioTrackEnabled(id: string) {
setStringAttr(this, MediaUIAttributes.MEDIA_AUDIO_TRACK_ENABLED, id);
}
#render(): void {
if (this.#prevState === JSON.stringify(this.mediaAudioTrackList)) return;
this.#prevState = JSON.stringify(this.mediaAudioTrackList);
const audioTrackList = this.mediaAudioTrackList;
this.defaultSlot.textContent = '';
for (const audioTrack of audioTrackList) {
const text = this.formatMenuItemText(audioTrack.label, audioTrack);
const item = createMenuItem({
type: 'radio',
text,
value: `${audioTrack.id}`,
checked: audioTrack.enabled,
});
item.prepend(createIndicator(this, 'checked-indicator'));
this.defaultSlot.append(item);
}
}
#onChange() {
if (this.value == null) return;
const event = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_AUDIO_TRACK_REQUEST,
{
composed: true,
bubbles: true,
detail: this.value,
}
);
this.dispatchEvent(event);
}
}
if (!globalThis.customElements.get('media-audio-track-menu')) {
globalThis.customElements.define(
'media-audio-track-menu',
MediaAudioTrackMenu
);
}
export { MediaAudioTrackMenu };
export default MediaAudioTrackMenu;

View File

@@ -0,0 +1,178 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { MediaUIAttributes } from '../constants.js';
import { nouns, tooltipLabels } from '../labels/labels.js';
import { MediaChromeMenuButton } from './media-chrome-menu-button.js';
import { getMediaController } from '../utils/element-utils.js';
import {
areSubsOn,
parseTextTracksStr,
stringifyTextTrackList,
} from '../utils/captions.js';
import { TextTrackLike } from '../utils/TextTrackLike.js';
const ccIconOn = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M22.83 5.68a2.58 2.58 0 0 0-2.3-2.5c-3.62-.24-11.44-.24-15.06 0a2.58 2.58 0 0 0-2.3 2.5c-.23 4.21-.23 8.43 0 12.64a2.58 2.58 0 0 0 2.3 2.5c3.62.24 11.44.24 15.06 0a2.58 2.58 0 0 0 2.3-2.5c.23-4.21.23-8.43 0-12.64Zm-11.39 9.45a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.92 3.92 0 0 1 .92-2.77 3.18 3.18 0 0 1 2.43-1 2.94 2.94 0 0 1 2.13.78c.364.359.62.813.74 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.17 1.61 1.61 0 0 0-1.29.58 2.79 2.79 0 0 0-.5 1.89 3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.48 1.48 0 0 0 1-.37 2.1 2.1 0 0 0 .59-1.14l1.4.44a3.23 3.23 0 0 1-1.07 1.69Zm7.22 0a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.88 3.88 0 0 1 .93-2.77 3.14 3.14 0 0 1 2.42-1 3 3 0 0 1 2.16.82 2.8 2.8 0 0 1 .73 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.21 1.61 1.61 0 0 0-1.29.58A2.79 2.79 0 0 0 15 12a3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.44 1.44 0 0 0 1-.37 2.1 2.1 0 0 0 .6-1.15l1.4.44a3.17 3.17 0 0 1-1.1 1.7Z"/>
</svg>`;
const ccIconOff = `<svg aria-hidden="true" viewBox="0 0 26 24">
<path d="M17.73 14.09a1.4 1.4 0 0 1-1 .37 1.579 1.579 0 0 1-1.27-.58A3 3 0 0 1 15 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34A2.89 2.89 0 0 0 19 9.07a3 3 0 0 0-2.14-.78 3.14 3.14 0 0 0-2.42 1 3.91 3.91 0 0 0-.93 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.17 3.17 0 0 0 1.07-1.74l-1.4-.45c-.083.43-.3.822-.62 1.12Zm-7.22 0a1.43 1.43 0 0 1-1 .37 1.58 1.58 0 0 1-1.27-.58A3 3 0 0 1 7.76 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34a2.81 2.81 0 0 0-.74-1.32 2.94 2.94 0 0 0-2.13-.78 3.18 3.18 0 0 0-2.43 1 4 4 0 0 0-.92 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.23 3.23 0 0 0 1.07-1.74l-1.4-.45a2.06 2.06 0 0 1-.6 1.07Zm12.32-8.41a2.59 2.59 0 0 0-2.3-2.51C18.72 3.05 15.86 3 13 3c-2.86 0-5.72.05-7.53.17a2.59 2.59 0 0 0-2.3 2.51c-.23 4.207-.23 8.423 0 12.63a2.57 2.57 0 0 0 2.3 2.5c1.81.13 4.67.19 7.53.19 2.86 0 5.72-.06 7.53-.19a2.57 2.57 0 0 0 2.3-2.5c.23-4.207.23-8.423 0-12.63Zm-1.49 12.53a1.11 1.11 0 0 1-.91 1.11c-1.67.11-4.45.18-7.43.18-2.98 0-5.76-.07-7.43-.18a1.11 1.11 0 0 1-.91-1.11c-.21-4.14-.21-8.29 0-12.43a1.11 1.11 0 0 1 .91-1.11C7.24 4.56 10 4.49 13 4.49s5.76.07 7.43.18a1.11 1.11 0 0 1 .91 1.11c.21 4.14.21 8.29 0 12.43Z"/>
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([aria-checked="true"]) slot[name=off] {
display: none !important;
}
${/* Double negative, but safer if display doesn't equal 'block' */ ''}
:host(:not([aria-checked="true"])) slot[name=on] {
display: none !important;
}
:host([aria-expanded="true"]) slot[name=tooltip] {
display: none;
}
</style>
<slot name="icon">
<slot name="on">${ccIconOn}</slot>
<slot name="off">${ccIconOff}</slot>
</slot>
`;
const updateAriaChecked = (el: HTMLElement): void => {
el.setAttribute('aria-checked', areSubsOn(el).toString());
};
/**
* @slot on - An element that will be shown while closed captions or subtitles are on.
* @slot off - An element that will be shown while closed captions or subtitles are off.
* @slot icon - An element for representing on and off states in a single icon
*
* @attr {string} mediasubtitleslist - (read-only) A list of all subtitles and captions.
* @attr {string} mediasubtitlesshowing - (read-only) A list of the showing subtitles and captions.
*
* @cssproperty [--media-captions-menu-button-display = inline-flex] - `display` property of button.
*/
class MediaCaptionsMenuButton extends MediaChromeMenuButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_SUBTITLES_LIST,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING,
];
}
#captionsReady: boolean;
constructor(options: Record<string, any> = {}) {
super({ slotTemplate, tooltipContent: tooltipLabels.CAPTIONS, ...options });
// Internal variable to keep track of when we have some or no captions (or subtitles, if using subtitles fallback)
// Used for `default-showing` behavior.
this.#captionsReady = false;
}
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-label', nouns.CLOSED_CAPTIONS());
updateAriaChecked(this);
}
attributeChangedCallback(
attrName: string,
oldValue: string,
newValue: string
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === MediaUIAttributes.MEDIA_SUBTITLES_SHOWING) {
updateAriaChecked(this);
}
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
* @return {HTMLElement | null}
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget != undefined) return super.invokeTargetElement;
return getMediaController(this)?.querySelector('media-captions-menu');
}
/**
* An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesList(): TextTrackLike[] {
return getSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST);
}
set mediaSubtitlesList(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST, list);
}
/**
* An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesShowing(): TextTrackLike[] {
return getSubtitlesListAttr(
this,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING
);
}
set mediaSubtitlesShowing(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_SHOWING, list);
}
}
/**
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName - The attribute name to get
* @returns An array of TextTrack-like objects.
*/
const getSubtitlesListAttr = (
el: HTMLElement,
attrName: string
): TextTrackLike[] => {
const attrVal = el.getAttribute(attrName);
return attrVal ? parseTextTracksStr(attrVal) : [];
};
/**
*
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName - The attribute name to set
* @param list - An array of TextTrack-like objects
*/
const setSubtitlesListAttr = (
el: HTMLElement,
attrName: string,
list: TextTrackLike[]
): void => {
// null, undefined, and empty arrays are treated as "no value" here
if (!list?.length) {
el.removeAttribute(attrName);
return;
}
// don't set if the new value is the same as existing
const newValStr = stringifyTextTrackList(list);
const oldVal = el.getAttribute(attrName);
if (oldVal === newValStr) return;
el.setAttribute(attrName, newValStr);
};
if (!globalThis.customElements.get('media-captions-menu-button')) {
globalThis.customElements.define(
'media-captions-menu-button',
MediaCaptionsMenuButton
);
}
export { MediaCaptionsMenuButton };
export default MediaCaptionsMenuButton;

View File

@@ -0,0 +1,233 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { MediaUIAttributes, MediaUIEvents } from '../constants.js';
import { getMediaController } from '../utils/element-utils.js';
import {
MediaChromeMenu,
createMenuItem,
createIndicator,
} from './media-chrome-menu.js';
import {
parseTextTracksStr,
stringifyTextTrackList,
formatTextTrackObj,
} from '../utils/captions.js';
import { TextTrackLike } from '../utils/TextTrackLike.js';
const ccIcon = /*html*/ `
<svg aria-hidden="true" viewBox="0 0 26 24" part="captions-indicator indicator">
<path d="M22.83 5.68a2.58 2.58 0 0 0-2.3-2.5c-3.62-.24-11.44-.24-15.06 0a2.58 2.58 0 0 0-2.3 2.5c-.23 4.21-.23 8.43 0 12.64a2.58 2.58 0 0 0 2.3 2.5c3.62.24 11.44.24 15.06 0a2.58 2.58 0 0 0 2.3-2.5c.23-4.21.23-8.43 0-12.64Zm-11.39 9.45a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.92 3.92 0 0 1 .92-2.77 3.18 3.18 0 0 1 2.43-1 2.94 2.94 0 0 1 2.13.78c.364.359.62.813.74 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.17 1.61 1.61 0 0 0-1.29.58 2.79 2.79 0 0 0-.5 1.89 3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.48 1.48 0 0 0 1-.37 2.1 2.1 0 0 0 .59-1.14l1.4.44a3.23 3.23 0 0 1-1.07 1.69Zm7.22 0a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.88 3.88 0 0 1 .93-2.77 3.14 3.14 0 0 1 2.42-1 3 3 0 0 1 2.16.82 2.8 2.8 0 0 1 .73 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.21 1.61 1.61 0 0 0-1.29.58A2.79 2.79 0 0 0 15 12a3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.44 1.44 0 0 0 1-.37 2.1 2.1 0 0 0 .6-1.15l1.4.44a3.17 3.17 0 0 1-1.1 1.7Z"/>
</svg>`;
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML =
MediaChromeMenu.template.innerHTML +
/*html*/ `
<slot name="captions-indicator" hidden>${ccIcon}</slot>`;
/**
* @extends {MediaChromeMenu}
*
* @slot - Default slotted elements.
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
* @slot captions-indicator - An icon element indicating an item with closed captions.
*
* @attr {string} mediasubtitleslist - (read-only) A list of all subtitles and captions.
* @attr {boolean} mediasubtitlesshowing - (read-only) A list of the showing subtitles and captions.
*/
class MediaCaptionsMenu extends MediaChromeMenu {
static template: HTMLTemplateElement = template;
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_SUBTITLES_LIST,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING,
];
}
#prevState: string | undefined;
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (
attrName === MediaUIAttributes.MEDIA_SUBTITLES_LIST &&
oldValue !== newValue
) {
this.#render();
} else if (
attrName === MediaUIAttributes.MEDIA_SUBTITLES_SHOWING &&
oldValue !== newValue
) {
this.value = newValue;
}
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('change', this.#onChange);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('change', this.#onChange);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement(): HTMLElement {
if (this.anchor !== 'auto') return super.anchorElement;
return getMediaController(this).querySelector('media-captions-menu-button');
}
/**
* @type {Array<object>} An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesList() {
return getSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST);
}
set mediaSubtitlesList(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_LIST, list);
}
/**
* An array of TextTrack-like objects.
* Objects must have the properties: kind, language, and label.
*/
get mediaSubtitlesShowing(): TextTrackLike[] {
return getSubtitlesListAttr(
this,
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING
);
}
set mediaSubtitlesShowing(list: TextTrackLike[]) {
setSubtitlesListAttr(this, MediaUIAttributes.MEDIA_SUBTITLES_SHOWING, list);
}
#render(): void {
if (this.#prevState === JSON.stringify(this.mediaSubtitlesList)) return;
this.#prevState = JSON.stringify(this.mediaSubtitlesList);
this.defaultSlot.textContent = '';
const isOff = !this.value;
const item = createMenuItem({
type: 'radio',
text: this.formatMenuItemText('Off'),
value: 'off',
checked: isOff,
});
item.prepend(createIndicator(this, 'checked-indicator'));
this.defaultSlot.append(item);
const subtitlesList = this.mediaSubtitlesList;
for (const subs of subtitlesList) {
const item = createMenuItem({
type: 'radio',
text: this.formatMenuItemText(subs.label, subs),
value: formatTextTrackObj(subs),
checked: this.value == formatTextTrackObj(subs),
});
item.prepend(createIndicator(this, 'checked-indicator'));
// add CC icon for captions
const type = subs.kind ?? 'subs';
if ((type as string) === 'captions') {
item.append(createIndicator(this, 'captions-indicator'));
}
this.defaultSlot.append(item);
}
}
#onChange(): void {
const showingSubs = this.mediaSubtitlesShowing;
const showingSubsStr = this.getAttribute(
MediaUIAttributes.MEDIA_SUBTITLES_SHOWING
);
// Don't make request if this was the result of a media state change (CJP)
const localStateChange = this.value !== showingSubsStr;
if (showingSubs?.length && localStateChange) {
// turn off currently selected tracks
this.dispatchEvent(
new globalThis.CustomEvent(
MediaUIEvents.MEDIA_DISABLE_SUBTITLES_REQUEST,
{
composed: true,
bubbles: true,
detail: showingSubs,
}
)
);
}
// Don't make request if this was the result of a media state change (CJP)
if (!this.value || !localStateChange) return;
const event = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_SHOW_SUBTITLES_REQUEST,
{
composed: true,
bubbles: true,
detail: this.value,
}
);
this.dispatchEvent(event);
}
}
/**
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName
* @returns An array of TextTrack-like objects.
*/
const getSubtitlesListAttr = (
el: HTMLElement,
attrName: string
): TextTrackLike[] => {
const attrVal = el.getAttribute(attrName);
return attrVal ? parseTextTracksStr(attrVal) : [];
};
/**
*
* @param el - Should be HTMLElement but issues with globalThis shim
* @param attrName
* @param {Array<Object>} list An array of TextTrack-like objects
*/
const setSubtitlesListAttr = (
el: HTMLElement,
attrName: string,
list: TextTrackLike[]
): void => {
// null, undefined, and empty arrays are treated as "no value" here
if (!list?.length) {
el.removeAttribute(attrName);
return;
}
// don't set if the new value is the same as existing
const newValStr = stringifyTextTrackList(list);
const oldVal = el.getAttribute(attrName);
if (oldVal === newValStr) return;
el.setAttribute(attrName, newValStr);
};
if (!globalThis.customElements.get('media-captions-menu')) {
globalThis.customElements.define('media-captions-menu', MediaCaptionsMenu);
}
export { MediaCaptionsMenu };
export default MediaCaptionsMenu;

View File

@@ -0,0 +1,54 @@
import { MediaChromeButton } from '../media-chrome-button.js';
import { globalThis } from '../utils/server-safe-globals.js';
import { InvokeEvent } from '../utils/events.js';
import { getDocumentOrShadowRoot } from '../utils/element-utils.js';
/**
* @attr {string} invoketarget - The id of the element to invoke when clicked.
*/
class MediaChromeMenuButton extends MediaChromeButton {
connectedCallback(): void {
super.connectedCallback();
if (this.invokeTargetElement) {
this.setAttribute('aria-haspopup', 'menu');
}
}
get invokeTarget(): string | null {
return this.getAttribute('invoketarget');
}
set invokeTarget(value: string | null) {
this.setAttribute('invoketarget', `${value}`);
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
* @return {HTMLElement | null}
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget) {
return getDocumentOrShadowRoot(this)?.querySelector(
`#${this.invokeTarget}`
) as HTMLElement | null;
}
return null;
}
handleClick() {
this.invokeTargetElement?.dispatchEvent(
new InvokeEvent({ relatedTarget: this })
);
}
}
if (!globalThis.customElements.get('media-chrome-menu-button')) {
globalThis.customElements.define(
'media-chrome-menu-button',
MediaChromeMenuButton
);
}
export { MediaChromeMenuButton };
export default MediaChromeMenuButton;

View File

@@ -0,0 +1,490 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { InvokeEvent } from '../utils/events.js';
import {
getDocumentOrShadowRoot,
containsComposedNode,
} from '../utils/element-utils.js';
import type MediaChromeMenu from './media-chrome-menu.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
transition: var(--media-menu-item-transition,
background .15s linear,
opacity .2s ease-in-out
);
outline: var(--media-menu-item-outline, 0);
outline-offset: var(--media-menu-item-outline-offset, -1px);
cursor: pointer;
display: flex;
align-items: center;
align-self: stretch;
justify-self: stretch;
white-space: nowrap;
white-space-collapse: collapse;
text-wrap: nowrap;
padding: .4em .8em .4em 1em;
}
:host(:focus-visible) {
box-shadow: var(--media-menu-item-focus-shadow, inset 0 0 0 2px rgb(27 127 204 / .9));
outline: var(--media-menu-item-hover-outline, 0);
outline-offset: var(--media-menu-item-hover-outline-offset, var(--media-menu-item-outline-offset, -1px));
}
:host(:hover) {
cursor: pointer;
background: var(--media-menu-item-hover-background, rgb(92 92 102 / .5));
outline: var(--media-menu-item-hover-outline);
outline-offset: var(--media-menu-item-hover-outline-offset, var(--media-menu-item-outline-offset, -1px));
}
:host([aria-checked="true"]) {
background: var(--media-menu-item-checked-background);
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
pointer-events: none;
color: rgba(255, 255, 255, .3);
}
slot:not([name]) {
width: 100%;
}
slot:not([name="submenu"]) {
display: inline-flex;
align-items: center;
transition: inherit;
opacity: var(--media-menu-item-opacity, 1);
}
slot[name="description"] {
justify-content: end;
}
slot[name="description"] > span {
display: inline-block;
margin-inline: 1em .2em;
max-width: var(--media-menu-item-description-max-width, 100px);
text-overflow: ellipsis;
overflow: hidden;
font-size: .8em;
font-weight: 400;
text-align: right;
position: relative;
top: .04em;
}
slot[name="checked-indicator"] {
display: none;
}
:host(:is([role="menuitemradio"],[role="menuitemcheckbox"])) slot[name="checked-indicator"] {
display: var(--media-menu-item-checked-indicator-display, inline-block);
}
${/* For all slotted icons in prefix and suffix. */ ''}
svg, img, ::slotted(svg), ::slotted(img) {
height: var(--media-menu-item-icon-height, var(--media-control-height, 24px));
fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)));
display: block;
}
${
/* Only for indicator icons like checked-indicator or captions-indicator. */ ''
}
[part~="indicator"],
::slotted([part~="indicator"]) {
fill: var(--media-menu-item-indicator-fill,
var(--media-icon-color, var(--media-primary-color, rgb(238 238 238))));
height: var(--media-menu-item-indicator-height, 1.25em);
margin-right: .5ch;
}
[part~="checked-indicator"] {
visibility: hidden;
}
:host([aria-checked="true"]) [part~="checked-indicator"] {
visibility: visible;
}
</style>
<slot name="checked-indicator">
<svg aria-hidden="true" viewBox="0 1 24 24" part="checked-indicator indicator">
<path d="m10 15.17 9.193-9.191 1.414 1.414-10.606 10.606-6.364-6.364 1.414-1.414 4.95 4.95Z"/>
</svg>
</slot>
<slot name="prefix"></slot>
<slot></slot>
<slot name="description"></slot>
<slot name="suffix"></slot>
<slot name="submenu"></slot>
`;
export const Attributes = {
TYPE: 'type',
VALUE: 'value',
CHECKED: 'checked',
DISABLED: 'disabled',
};
/**
* @extends {HTMLElement}
* @slot - Default slotted elements.
*
* @attr {(''|'radio'|'checkbox')} type - This attribute indicates the kind of command, and can be one of three values.
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
*
* @cssproperty --media-menu-item-opacity - `opacity` of menu-item content.
* @cssproperty --media-menu-item-transition - `transition` of menu-item.
* @cssproperty --media-menu-item-checked-background - `background` of checked menu-item.
* @cssproperty --media-menu-item-outline - `outline` menu-item.
* @cssproperty --media-menu-item-outline-offset - `outline-offset` of menu-item.
* @cssproperty --media-menu-item-hover-background - `background` of hovered menu-item.
* @cssproperty --media-menu-item-hover-outline - `outline` of hovered menu-item.
* @cssproperty --media-menu-item-hover-outline-offset - `outline-offset` of hovered menu-item.
* @cssproperty --media-menu-item-focus-shadow - `box-shadow` of the :focus-visible state.
* @cssproperty --media-menu-item-icon-height - `height` of icon.
* @cssproperty --media-menu-item-description-max-width - `max-width` of description.
* @cssproperty --media-menu-item-checked-indicator-display - `display` of checked indicator.
*
* @cssproperty --media-icon-color - `fill` color of icon.
* @cssproperty --media-menu-icon-height - `height` of icon.
*
* @cssproperty --media-menu-item-indicator-fill - `fill` color of indicator icon.
* @cssproperty --media-menu-item-indicator-height - `height` of menu-item indicator.
*/
class MediaChromeMenuItem extends globalThis.HTMLElement {
static template = template;
static get observedAttributes() {
return [
Attributes.TYPE,
Attributes.DISABLED,
Attributes.CHECKED,
Attributes.VALUE,
];
}
#dirty = false;
#ownerElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
// @ts-ignore
this.shadowRoot.append(this.constructor.template.content.cloneNode(true));
}
this.shadowRoot.addEventListener('slotchange', this);
}
enable() {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '-1');
}
if (isCheckable(this) && !this.hasAttribute('aria-checked')) {
this.setAttribute('aria-checked', 'false');
}
this.addEventListener('click', this);
this.addEventListener('keydown', this);
}
disable() {
this.removeAttribute('tabindex');
this.removeEventListener('click', this);
this.removeEventListener('keydown', this);
this.removeEventListener('keyup', this);
}
handleEvent(event) {
switch (event.type) {
case 'slotchange':
this.#handleSlotChange(event);
break;
case 'click':
this.handleClick(event);
break;
case 'keydown':
this.#handleKeyDown(event);
break;
case 'keyup':
this.#handleKeyUp(event);
break;
}
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.CHECKED && isCheckable(this) && !this.#dirty) {
this.setAttribute('aria-checked', newValue != null ? 'true' : 'false');
} else if (attrName === Attributes.TYPE && newValue !== oldValue) {
this.role = 'menuitem' + newValue;
} else if (attrName === Attributes.DISABLED && newValue !== oldValue) {
if (newValue == null) {
this.enable();
} else {
this.disable();
}
}
}
connectedCallback(): void {
if (!this.hasAttribute(Attributes.DISABLED)) {
this.enable();
}
this.role = 'menuitem' + this.type;
this.#ownerElement = closestMenuItemsContainer(this, this.parentNode);
this.#reset();
}
disconnectedCallback(): void {
this.disable();
this.#reset();
this.#ownerElement = null;
}
get invokeTarget() {
return this.getAttribute('invoketarget');
}
set invokeTarget(value) {
this.setAttribute('invoketarget', `${value}`);
}
/**
* Returns the element with the id specified by the `invoketarget` attribute
* or the slotted submenu element.
*/
get invokeTargetElement(): MediaChromeMenu | null {
if (this.invokeTarget) {
return getDocumentOrShadowRoot(this)?.querySelector(
`#${this.invokeTarget}`
);
}
return this.submenuElement;
}
/**
* Returns the slotted submenu element.
*/
get submenuElement(): MediaChromeMenu | null {
/** @type {HTMLSlotElement} */
const submenuSlot: HTMLSlotElement = this.shadowRoot.querySelector(
'slot[name="submenu"]'
);
return submenuSlot.assignedElements({
flatten: true,
})[0] as MediaChromeMenu;
}
get type() {
return this.getAttribute(Attributes.TYPE) ?? '';
}
set type(val) {
this.setAttribute(Attributes.TYPE, `${val}`);
}
get value() {
return this.getAttribute(Attributes.VALUE) ?? this.text;
}
set value(val) {
this.setAttribute(Attributes.VALUE, val);
}
get text() {
return (this.textContent ?? '').trim();
}
get checked() {
if (!isCheckable(this)) return undefined;
return this.getAttribute('aria-checked') === 'true';
}
set checked(value) {
if (!isCheckable(this)) return;
this.#dirty = true;
// Firefox doesn't support the property .ariaChecked.
this.setAttribute('aria-checked', value ? 'true' : 'false');
if (value) {
this.part.add('checked');
} else {
this.part.remove('checked');
}
}
#handleSlotChange(event) {
const slot = event.target;
const isDefaultSlot = !slot?.name;
if (isDefaultSlot) {
for (const node of slot.assignedNodes({ flatten: true })) {
// Remove all whitespace text nodes so the unnamed slot shows its fallback content.
if (node instanceof Text && node.textContent.trim() === '') {
node.remove();
}
}
}
if (slot.name === 'submenu') {
if (this.submenuElement) {
this.#submenuConnected();
} else {
this.#submenuDisconnected();
}
}
}
async #submenuConnected() {
this.setAttribute('aria-haspopup', 'menu');
this.setAttribute('aria-expanded', `${!this.submenuElement.hidden}`);
this.submenuElement.addEventListener('change', this.#handleMenuItem);
this.submenuElement.addEventListener('addmenuitem', this.#handleMenuItem);
this.submenuElement.addEventListener(
'removemenuitem',
this.#handleMenuItem
);
this.#handleMenuItem();
}
#submenuDisconnected() {
this.removeAttribute('aria-haspopup');
this.removeAttribute('aria-expanded');
this.submenuElement.removeEventListener('change', this.#handleMenuItem);
this.submenuElement.removeEventListener(
'addmenuitem',
this.#handleMenuItem
);
this.submenuElement.removeEventListener(
'removemenuitem',
this.#handleMenuItem
);
this.#handleMenuItem();
}
/**
* If there is a slotted submenu the fallback content of the description slot
* is populated with the text of the first checked item.
*/
#handleMenuItem = () => {
this.setAttribute('submenusize', `${this.submenuElement.items.length}`);
const descriptionSlot = this.shadowRoot.querySelector(
'slot[name="description"]'
);
const checkedItem = this.submenuElement.checkedItems?.[0];
const description = checkedItem?.dataset.description ?? checkedItem?.text;
const span = document.createElement('span');
span.textContent = description ?? '';
descriptionSlot.replaceChildren(span);
};
handleClick(event) {
// Checkable menu items are handled in media-chrome-menu.
if (isCheckable(this)) return;
if (this.invokeTargetElement && containsComposedNode(this, event.target)) {
this.invokeTargetElement.dispatchEvent(
new InvokeEvent({ relatedTarget: this })
);
}
}
get keysUsed() {
return ['Enter', ' '];
}
#handleKeyUp(event) {
const { key } = event;
if (!this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#handleKeyUp);
return;
}
this.handleClick(event);
}
#handleKeyDown(event) {
const { metaKey, altKey, key } = event;
if (metaKey || altKey || !this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#handleKeyUp);
return;
}
this.addEventListener('keyup', this.#handleKeyUp, { once: true });
}
#reset() {
const items = this.#ownerElement?.radioGroupItems;
if (!items) return;
// Default to the last aria-checked element if there isn't an active element already.
let checkedItem = items
.filter((item) => item.getAttribute('aria-checked') === 'true')
.pop();
// If there isn't an active element or a checked element, default to the first element.
if (!checkedItem) checkedItem = items[0];
for (const item of items) {
item.setAttribute('aria-checked', 'false');
}
checkedItem?.setAttribute('aria-checked', 'true');
}
}
function isCheckable(item) {
return item.type === 'radio' || item.type === 'checkbox';
}
function closestMenuItemsContainer(childNode, parentNode) {
if (!childNode) return null;
const { host } = childNode.getRootNode();
if (!parentNode && host) return closestMenuItemsContainer(childNode, host);
if (parentNode?.items) return parentNode;
return closestMenuItemsContainer(parentNode, parentNode?.parentNode);
}
if (!globalThis.customElements.get('media-chrome-menu-item')) {
globalThis.customElements.define(
'media-chrome-menu-item',
MediaChromeMenuItem
);
}
export { MediaChromeMenuItem };
export default MediaChromeMenuItem;

View File

@@ -0,0 +1,965 @@
import { MediaStateReceiverAttributes } from '../constants.js';
import { globalThis, document } from '../utils/server-safe-globals.js';
import { computePosition } from '../utils/anchor-utils.js';
import { observeResize, unobserveResize } from '../utils/resize-observer.js';
import { ToggleEvent, InvokeEvent } from '../utils/events.js';
import {
getActiveElement,
containsComposedNode,
closestComposedNode,
insertCSSRule,
getMediaController,
getAttributeMediaController,
getDocumentOrShadowRoot,
} from '../utils/element-utils.js';
import MediaChromeMenuItem from './media-chrome-menu-item.js';
import MediaController from '../media-controller.js';
export function createMenuItem({
type,
text,
value,
checked,
}: {
type?: string;
text: string;
value: string;
checked: boolean;
}) {
const item = document.createElement(
'media-chrome-menu-item'
) as MediaChromeMenuItem;
item.type = type ?? '';
item.part.add('menu-item');
if (type) item.part.add(type);
item.value = value;
item.checked = checked;
const label = document.createElement('span');
label.textContent = text;
item.append(label);
return item;
}
export function createIndicator(el: HTMLElement, name: string) {
let customIndicator = el.querySelector(`:scope > [slot="${name}"]`);
// Chaining slots
if (customIndicator?.nodeName == 'SLOT')
// @ts-ignore
customIndicator = customIndicator.assignedElements({ flatten: true })[0];
if (customIndicator) {
// @ts-ignore
customIndicator = customIndicator.cloneNode(true);
return customIndicator;
}
const fallbackIndicator = el.shadowRoot.querySelector(
`[name="${name}"] > svg`
);
if (fallbackIndicator) {
return fallbackIndicator.cloneNode(true);
}
// Return an empty string if no indicator is found to use the slot fallback.
return '';
}
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
:host {
font: var(--media-font,
var(--media-font-weight, normal)
var(--media-font-size, 14px) /
var(--media-text-content-height, var(--media-control-height, 24px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
background: var(--media-menu-background, var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .8))));
border-radius: var(--media-menu-border-radius);
border: var(--media-menu-border, none);
display: var(--media-menu-display, inline-flex);
transition: var(--media-menu-transition-in,
visibility 0s,
opacity .2s ease-out,
transform .15s ease-out,
left .2s ease-in-out,
min-width .2s ease-in-out,
min-height .2s ease-in-out
) !important;
${/* ^^Prevent transition override by media-container */ ''}
visibility: var(--media-menu-visibility, visible);
opacity: var(--media-menu-opacity, 1);
max-height: var(--media-menu-max-height, var(--_menu-max-height, 300px));
transform: var(--media-menu-transform-in, translateY(0) scale(1));
flex-direction: column;
${/* Prevent overflowing a flex container */ ''}
min-height: 0;
position: relative;
bottom: var(--_menu-bottom);
box-sizing: border-box;
}
:host([hidden]) {
transition: var(--media-menu-transition-out,
visibility .15s ease-in,
opacity .15s ease-in,
transform .15s ease-in
) !important;
visibility: var(--media-menu-hidden-visibility, hidden);
opacity: var(--media-menu-hidden-opacity, 0);
max-height: var(--media-menu-hidden-max-height,
var(--media-menu-max-height, var(--_menu-max-height, 300px)));
transform: var(--media-menu-transform-out, translateY(2px) scale(.99));
pointer-events: none;
}
:host([slot="submenu"]) {
background: none;
width: 100%;
min-height: 100%;
position: absolute;
bottom: 0;
right: -100%;
}
#container {
display: flex;
flex-direction: column;
min-height: 0;
transition: transform .2s ease-out;
transform: translate(0, 0);
}
#container.has-expanded {
transition: transform .2s ease-in;
transform: translate(-100%, 0);
}
button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
outline: inherit;
display: inline-flex;
align-items: center;
}
slot[name="header"][hidden] {
display: none;
}
slot[name="header"] > *,
slot[name="header"]::slotted(*) {
padding: .4em .7em;
border-bottom: 1px solid rgb(255 255 255 / .25);
cursor: default;
}
slot[name="header"] > button[part~="back"],
slot[name="header"]::slotted(button[part~="back"]) {
cursor: pointer;
}
svg[part~="back"] {
height: var(--media-menu-icon-height, var(--media-control-height, 24px));
fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)));
display: block;
margin-right: .5ch;
}
slot:not([name]) {
gap: var(--media-menu-gap);
flex-direction: var(--media-menu-flex-direction, column);
overflow: var(--media-menu-overflow, hidden auto);
display: flex;
min-height: 0;
}
:host([role="menu"]) slot:not([name]) {
padding-block: .4em;
}
slot:not([name])::slotted([role="menu"]) {
background: none;
}
media-chrome-menu-item > span {
margin-right: .5ch;
max-width: var(--media-menu-item-max-width);
text-overflow: ellipsis;
overflow: hidden;
}
</style>
<style id="layout-row" media="width:0">
slot[name="header"] > *,
slot[name="header"]::slotted(*) {
padding: .4em .5em;
}
slot:not([name]) {
gap: var(--media-menu-gap, .25em);
flex-direction: var(--media-menu-flex-direction, row);
padding-inline: .5em;
}
media-chrome-menu-item {
padding: .3em .5em;
}
media-chrome-menu-item[aria-checked="true"] {
background: var(--media-menu-item-checked-background, rgb(255 255 255 / .2));
}
${/* In row layout hide the checked indicator completely. */ ''}
media-chrome-menu-item::part(checked-indicator) {
display: var(--media-menu-item-checked-indicator-display, none);
}
</style>
<div id="container">
<slot name="header" hidden>
<button part="back button" aria-label="Back to previous menu">
<slot name="back-icon">
<svg aria-hidden="true" viewBox="0 0 20 24" part="back indicator">
<path d="m11.88 17.585.742-.669-4.2-4.665 4.2-4.666-.743-.669-4.803 5.335 4.803 5.334Z"/>
</svg>
</slot>
<slot name="title"></slot>
</button>
</slot>
<slot></slot>
</div>
<slot name="checked-indicator" hidden></slot>
`;
export const Attributes = {
STYLE: 'style',
HIDDEN: 'hidden',
DISABLED: 'disabled',
ANCHOR: 'anchor',
} as const;
/**
* @extends {HTMLElement}
*
* @slot - Default slotted elements.
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
*
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
*
* @cssproperty --media-primary-color - Default color of text / icon.
* @cssproperty --media-secondary-color - Default color of background.
* @cssproperty --media-text-color - `color` of text.
*
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-menu-display - `display` of menu.
* @cssproperty --media-menu-layout - Set to `row` for a horizontal menu design.
* @cssproperty --media-menu-flex-direction - `flex-direction` of menu.
* @cssproperty --media-menu-gap - `gap` between menu items.
* @cssproperty --media-menu-background - `background` of menu.
* @cssproperty --media-menu-border-radius - `border-radius` of menu.
* @cssproperty --media-menu-border - `border` of menu.
* @cssproperty --media-menu-transition-in - `transition` of menu when showing.
* @cssproperty --media-menu-transition-out - `transition` of menu when hiding.
* @cssproperty --media-menu-visibility - `visibility` of menu when showing.
* @cssproperty --media-menu-hidden-visibility - `visibility` of menu when hiding.
* @cssproperty --media-menu-max-height - `max-height` of menu.
* @cssproperty --media-menu-hidden-max-height - `max-height` of menu when hiding.
* @cssproperty --media-menu-opacity - `opacity` of menu when showing.
* @cssproperty --media-menu-hidden-opacity - `opacity` of menu when hiding.
* @cssproperty --media-menu-transform-in - `transform` of menu when showing.
* @cssproperty --media-menu-transform-out - `transform` of menu when hiding.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of text.
*
* @cssproperty --media-icon-color - `fill` color of icon.
* @cssproperty --media-menu-icon-height - `height` of icon.
* @cssproperty --media-menu-item-checked-indicator-display - `display` of check indicator.
* @cssproperty --media-menu-item-checked-background - `background` of checked menu item.
* @cssproperty --media-menu-item-max-width - `max-width` of menu item text.
*/
class MediaChromeMenu extends globalThis.HTMLElement {
static template: HTMLTemplateElement = template;
static get observedAttributes(): string[] {
return [
Attributes.DISABLED,
Attributes.HIDDEN,
Attributes.STYLE,
Attributes.ANCHOR,
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
];
}
static formatMenuItemText(text: string): string {
return text;
}
#mediaController: MediaController | null = null;
#previouslyFocused: HTMLElement | null = null;
#invokerElement: HTMLElement | null = null;
#previousItems = new Set<MediaChromeMenuItem>();
#mutationObserver: MutationObserver;
#isPopover = false;
#cssRule: CSSStyleRule | null = null;
nativeEl: HTMLElement;
container: HTMLElement;
defaultSlot: HTMLSlotElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.nativeEl = (
this.constructor as typeof MediaChromeMenu
).template.content.cloneNode(true) as HTMLElement;
this.shadowRoot.append(this.nativeEl);
}
this.container = this.shadowRoot.querySelector('#container') as HTMLElement;
this.defaultSlot = this.shadowRoot.querySelector(
'slot:not([name])'
) as HTMLSlotElement;
this.shadowRoot.addEventListener('slotchange', this);
this.#mutationObserver = new MutationObserver(this.#handleMenuItems);
this.#mutationObserver.observe(this.defaultSlot, { childList: true });
}
enable(): void {
this.addEventListener('click', this);
this.addEventListener('focusout', this);
this.addEventListener('keydown', this);
this.addEventListener('invoke', this);
this.addEventListener('toggle', this);
}
disable(): void {
this.removeEventListener('click', this);
this.removeEventListener('focusout', this);
this.removeEventListener('keyup', this);
this.removeEventListener('invoke', this);
this.removeEventListener('toggle', this);
}
handleEvent(event: Event): void {
switch (event.type) {
case 'slotchange':
this.#handleSlotChange(event as Event);
break;
case 'invoke':
this.#handleInvoke(event as InvokeEvent);
break;
case 'click':
this.#handleClick(event as MouseEvent);
break;
case 'toggle':
this.#handleToggle(event as ToggleEvent);
break;
case 'focusout':
this.#handleFocusOut(event as FocusEvent);
break;
case 'keydown':
this.#handleKeyDown(event as KeyboardEvent);
break;
}
}
connectedCallback(): void {
this.#cssRule = insertCSSRule(this.shadowRoot, ':host');
this.#updateLayoutStyle();
if (!this.hasAttribute('disabled')) {
this.enable();
}
if (!this.role) {
// set menu role on the media-chrome-menu element itself
// this is to make sure that SRs announce items as being part
// of a menu when focused
this.role = 'menu';
}
this.#mediaController = getAttributeMediaController(this);
this.#mediaController?.associateElement?.(this);
if (!this.hidden) {
observeResize(getBoundsElement(this), this.#handleBoundsResize);
observeResize(this, this.#handleMenuResize);
}
}
disconnectedCallback(): void {
unobserveResize(getBoundsElement(this), this.#handleBoundsResize);
unobserveResize(this, this.#handleMenuResize);
this.disable();
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.HIDDEN && newValue !== oldValue) {
if (!this.#isPopover) this.#isPopover = true;
if (this.hidden) {
this.#handleClosed();
} else {
this.#handleOpen();
}
// Fire a toggle event from a submenu which can be used in a parent menu.
this.dispatchEvent(
new ToggleEvent({
oldState: this.hidden ? 'open' : 'closed',
newState: this.hidden ? 'closed' : 'open',
bubbles: true,
})
);
} else if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
this.#mediaController = getAttributeMediaController(this);
this.#mediaController?.associateElement?.(this);
}
} else if (attrName === Attributes.DISABLED && newValue !== oldValue) {
if (newValue == null) {
this.enable();
} else {
this.disable();
}
} else if (attrName === Attributes.STYLE && newValue !== oldValue) {
this.#updateLayoutStyle();
}
}
formatMenuItemText(text: string, data?: any) {
// @ts-ignore
return this.constructor.formatMenuItemText(text, data);
}
get anchor() {
return this.getAttribute('anchor');
}
set anchor(value: string) {
this.setAttribute('anchor', `${value}`);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor) {
return getDocumentOrShadowRoot(this)?.querySelector<HTMLElement>(`#${this.anchor}`);
}
return null;
}
/**
* Returns the menu items.
*/
get items(): MediaChromeMenuItem[] {
return this.defaultSlot
.assignedElements({ flatten: true })
.filter(isMenuItem);
}
get radioGroupItems(): MediaChromeMenuItem[] {
return this.items.filter((item) => item.role === 'menuitemradio');
}
get checkedItems(): MediaChromeMenuItem[] {
return this.items.filter((item) => item.checked);
}
get value(): string {
return this.checkedItems[0]?.value ?? '';
}
set value(newValue: string) {
const item = this.items.find((item) => item.value === newValue);
if (!item) return;
this.#selectItem(item);
}
#handleSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
// @ts-ignore
for (const node of slot.assignedNodes({ flatten: true }) as HTMLElement[]) {
// Remove all whitespace text nodes so the unnamed slot shows its fallback content.
if (node.nodeType === 3 && node.textContent.trim() === '') {
node.remove();
}
}
if (['header', 'title'].includes(slot.name)) {
const header: HTMLElement = this.shadowRoot.querySelector(
'slot[name="header"]'
);
header.hidden = slot.assignedNodes().length === 0;
}
if (!slot.name) {
this.#handleMenuItems();
}
}
/**
* Fires an event when a menu item is added or removed.
* This is needed to update the description slot of an ancestor menu item.
*/
#handleMenuItems = () => {
const previousItems = this.#previousItems;
const currentItems = new Set(this.items);
for (const item of previousItems) {
if (!currentItems.has(item)) {
this.dispatchEvent(new CustomEvent('removemenuitem', { detail: item }));
}
}
for (const item of currentItems) {
if (!previousItems.has(item)) {
this.dispatchEvent(new CustomEvent('addmenuitem', { detail: item }));
}
}
this.#previousItems = currentItems;
};
/**
* Sets the layout style for the menu.
* It can be a row or column layout. e.g. playback-rate-menu
*/
#updateLayoutStyle() {
const layoutRowStyle = this.shadowRoot.querySelector('#layout-row');
const menuLayout = getComputedStyle(this)
.getPropertyValue('--media-menu-layout')
?.trim();
layoutRowStyle.setAttribute('media', menuLayout === 'row' ? '' : 'width:0');
}
#handleInvoke(event: InvokeEvent) {
this.#invokerElement = event.relatedTarget as HTMLElement;
if (!containsComposedNode(this, event.relatedTarget)) {
this.hidden = !this.hidden;
}
}
#handleOpen() {
this.#invokerElement?.setAttribute('aria-expanded', 'true');
// Focus when the transition ends.
this.addEventListener('transitionend', () => this.focus(), { once: true });
// A resize callback is also fired when the menu is opened.
observeResize(getBoundsElement(this), this.#handleBoundsResize);
observeResize(this, this.#handleMenuResize);
}
#handleClosed() {
this.#invokerElement?.setAttribute('aria-expanded', 'false');
unobserveResize(getBoundsElement(this), this.#handleBoundsResize);
unobserveResize(this, this.#handleMenuResize);
}
#handleBoundsResize = () => {
this.#positionMenu();
this.#resizeMenu(false);
};
#handleMenuResize = () => {
this.#positionMenu();
};
/**
* Updates the popover menu position based on the anchor element.
* @param {number} [menuWidth]
*/
#positionMenu(menuWidth?: number) {
// Can't position if the menu doesn't have an anchor and isn't a child of a media controller.
if (this.hasAttribute('mediacontroller') && !this.anchor) return;
// If the menu is hidden or there is no anchor, skip updating the menu position.
if (this.hidden || !this.anchorElement) return;
const { x, y } = computePosition({
anchor: this.anchorElement,
floating: this,
placement: 'top-start',
});
menuWidth ??= this.offsetWidth;
const bounds = getBoundsElement(this);
const boundsRect = bounds.getBoundingClientRect();
const right = boundsRect.width - x - menuWidth;
const bottom = boundsRect.height - y - this.offsetHeight;
const { style } = this.#cssRule;
style.setProperty('position', 'absolute');
style.setProperty('right', `${Math.max(0, right)}px`);
style.setProperty('--_menu-bottom', `${bottom}px`);
// Determine the real bottom value that is used for the max-height calculation.
// `bottom` could have been overridden externally.
const computedStyle = getComputedStyle(this);
const isBottomCalc = style.getPropertyValue('--_menu-bottom') === computedStyle.bottom;
const realBottom = isBottomCalc ? bottom : parseFloat(computedStyle.bottom);
const maxHeight = boundsRect.height - realBottom - parseFloat(computedStyle.marginBottom);
// Safari required directly setting the element style property instead of
// updating the style node for the styles to be refreshed.
this.style.setProperty('--_menu-max-height', `${maxHeight}px`);
}
/**
* Resize this menu to fit the submenu.
* @param {boolean} animate
*/
#resizeMenu(animate: boolean) {
const expandedMenuItem = this.querySelector(
'[role="menuitem"][aria-haspopup][aria-expanded="true"]'
) as MediaChromeMenuItem;
const expandedSubmenu = expandedMenuItem?.querySelector(
'[role="menu"]'
) as MediaChromeMenu;
const { style } = this.#cssRule;
if (!animate) {
style.setProperty('--media-menu-transition-in', 'none');
}
if (expandedSubmenu) {
const height = expandedSubmenu.offsetHeight;
const width = Math.max(
expandedSubmenu.offsetWidth,
expandedMenuItem.offsetWidth
);
// Safari required directly setting the style property instead of
// updating the style node for the min-width or min-height to work.
this.style.setProperty('min-width', `${width}px`);
this.style.setProperty('min-height', `${height}px`);
this.#positionMenu(width);
} else {
this.style.removeProperty('min-width');
this.style.removeProperty('min-height');
this.#positionMenu();
}
style.removeProperty('--media-menu-transition-in');
}
focus() {
this.#previouslyFocused = getActiveElement();
if (this.items.length) {
this.#setTabItem(this.items[0]);
this.items[0].focus();
return;
}
// If there are no menu items, focus on the first focusable child.
const focusable = this.querySelector(
'[autofocus], [tabindex]:not([tabindex="-1"]), [role="menu"]'
) as HTMLElement;
focusable?.focus();
}
#handleClick(event: MouseEvent) {
// Prevent running this in a parent menu if the event target is a sub menu.
event.stopPropagation();
if (event.composedPath().includes(this.#backButtonElement)) {
this.#previouslyFocused?.focus();
this.hidden = true;
return;
}
const item = this.#getItem(event);
if (!item || item.hasAttribute('disabled')) return;
this.#setTabItem(item);
this.handleSelect(event);
}
get #backButtonElement() {
const headerSlot = this.shadowRoot.querySelector(
'slot[name="header"]'
) as HTMLSlotElement;
return headerSlot
.assignedElements({ flatten: true })
?.find(
(el) => el.matches('button[part~="back"]')
) as HTMLElement;
}
handleSelect(event: MouseEvent | KeyboardEvent): void {
const item = this.#getItem(event);
if (!item) return;
this.#selectItem(item, item.type === 'checkbox');
// If the menu was opened by a click, close it when selecting an item.
if (this.#invokerElement && !this.hidden) {
this.#previouslyFocused?.focus();
this.hidden = true;
}
}
/**
* Handle the toggle event of submenus.
* Closes all other open submenus when opening a submenu.
* Resizes this menu to fit the submenu.
*
* @param {ToggleEvent} event
*/
#handleToggle(event: ToggleEvent): void {
// Only handle events of submenus.
if (event.target === this) return;
this.#checkSubmenuHasExpanded();
const menuItemsWithSubmenu = Array.from(
this.querySelectorAll('[role="menuitem"][aria-haspopup]')
) as MediaChromeMenuItem[];
// Close all other open submenus.
for (const item of menuItemsWithSubmenu) {
if (item.invokeTargetElement == event.target) continue;
if (
event.newState == 'open' &&
item.getAttribute('aria-expanded') == 'true' &&
!item.invokeTargetElement.hidden
) {
item.invokeTargetElement.dispatchEvent(
new InvokeEvent({ relatedTarget: item })
);
}
}
// Keep the aria-expanded attribute in sync with the hidden state of the submenu.
// This is needed when loading media-chrome with an unhidden submenu.
for (const item of menuItemsWithSubmenu) {
item.setAttribute('aria-expanded', `${!item.submenuElement.hidden}`);
}
this.#resizeMenu(true);
}
/**
* Check if any submenu is expanded and update the container class accordingly.
* When the CSS :has() selector is supported, this can be done with CSS only.
*/
#checkSubmenuHasExpanded() {
const selector = '[role="menuitem"] > [role="menu"]:not([hidden])';
const expandedMenuItem = this.querySelector(selector);
this.container.classList.toggle('has-expanded', !!expandedMenuItem);
}
#handleFocusOut(event: FocusEvent) {
if (!containsComposedNode(this, event.relatedTarget as Node)) {
if (this.#isPopover) {
this.#previouslyFocused?.focus();
}
// If the menu was opened by a click, close it when selecting an item.
if (
this.#invokerElement &&
this.#invokerElement !== event.relatedTarget &&
!this.hidden
) {
this.hidden = true;
}
}
}
get keysUsed() {
return [
'Enter',
'Escape',
'Tab',
' ',
'ArrowDown',
'ArrowUp',
'Home',
'End',
];
}
#handleKeyDown(event: KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey } = event;
if (ctrlKey || altKey || metaKey) {
return;
}
if (!this.keysUsed.includes(key)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (key === 'Tab') {
if (this.#isPopover) {
// Close all menus when tabbing out.
this.hidden = true;
return;
}
// Move focus to the previous focusable element.
if (event.shiftKey) {
(this.previousElementSibling as HTMLElement)?.focus?.();
} else {
// Move focus to the next focusable element.
(this.nextElementSibling as HTMLElement)?.focus?.();
}
// Go back to the previous focused element.
this.blur();
} else if (key === 'Escape') {
// Go back to the previous menu or close the menu.
this.#previouslyFocused?.focus();
if (this.#isPopover) {
this.hidden = true;
}
} else if (key === 'Enter' || key === ' ') {
this.handleSelect(event);
} else {
this.handleMove(event);
}
}
#getItem(event: MouseEvent | KeyboardEvent) {
return event.composedPath().find((el) => {
return ['menuitemradio', 'menuitemcheckbox'].includes(
(el as HTMLElement).role
);
}) as MediaChromeMenuItem | undefined;
}
#getTabItem() {
return this.items.find((item) => item.tabIndex === 0);
}
#setTabItem(tabItem: MediaChromeMenuItem) {
for (const item of this.items) {
item.tabIndex = item === tabItem ? 0 : -1;
}
}
#selectItem(item: MediaChromeMenuItem, toggle?: boolean) {
const oldCheckedItems = [...this.checkedItems];
if (item.type === 'radio') {
this.radioGroupItems.forEach((el) => (el.checked = false));
}
if (toggle) {
item.checked = !item.checked;
} else {
item.checked = true;
}
if (this.checkedItems.some((opt, i) => opt != oldCheckedItems[i])) {
this.dispatchEvent(
new Event('change', { bubbles: true, composed: true })
);
}
}
handleMove(event: KeyboardEvent) {
const { key } = event;
const items = this.items;
const currentItem = this.#getItem(event) ?? this.#getTabItem() ?? items[0];
const currentIndex = items.indexOf(currentItem);
let index = Math.max(0, currentIndex);
if (key === 'ArrowDown') {
index++;
} else if (key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.#setTabItem(items[index]);
items[index].focus();
}
}
function isMenuItem(element: any): element is MediaChromeMenuItem {
return ['menuitem', 'menuitemradio', 'menuitemcheckbox'].includes(
element?.role
);
}
function getBoundsElement(host: HTMLElement) {
return ((host.getAttribute('bounds')
? closestComposedNode(host, `#${host.getAttribute('bounds')}`)
: getMediaController(host) || host.parentElement) ?? host) as HTMLElement;
}
if (!globalThis.customElements.get('media-chrome-menu')) {
globalThis.customElements.define('media-chrome-menu', MediaChromeMenu);
}
export { MediaChromeMenu };
export default MediaChromeMenu;

View File

@@ -0,0 +1,133 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { MediaUIAttributes } from '../constants.js';
import { nouns, tooltipLabels } from '../labels/labels.js';
import { MediaChromeMenuButton } from './media-chrome-menu-button.js';
import { AttributeTokenList } from '../utils/attribute-token-list.js';
import {
getNumericAttr,
setNumericAttr,
getMediaController,
} from '../utils/element-utils.js';
export const Attributes = {
RATES: 'rates',
};
export const DEFAULT_RATES = [1, 1.2, 1.5, 1.7, 2];
export const DEFAULT_RATE = 1;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host {
min-width: 5ch;
padding: var(--media-button-padding, var(--media-control-padding, 10px 5px));
}
:host([aria-expanded="true"]) slot[name=tooltip] {
display: none;
}
</style>
<slot name="icon"></slot>
`;
/**
* @attr {string} rates - Set custom playback rates for the user to choose from.
* @attr {string} mediaplaybackrate - (read-only) Set to the media playback rate.
*
* @cssproperty [--media-playback-rate-menu-button-display = inline-flex] - `display` property of button.
*/
class MediaPlaybackRateMenuButton extends MediaChromeMenuButton {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
Attributes.RATES,
];
}
#rates = new AttributeTokenList(this, Attributes.RATES, {
defaultValue: DEFAULT_RATES,
});
container: HTMLSlotElement;
constructor(options = {}) {
super({
slotTemplate,
tooltipContent: tooltipLabels.PLAYBACK_RATE,
...options,
});
this.container = this.shadowRoot.querySelector('slot[name="icon"]');
this.container.innerHTML = `${DEFAULT_RATE}x`;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === Attributes.RATES) {
this.#rates.value = newValue;
}
if (attrName === MediaUIAttributes.MEDIA_PLAYBACK_RATE) {
const newPlaybackRate = newValue ? +newValue : Number.NaN;
const playbackRate = !Number.isNaN(newPlaybackRate)
? newPlaybackRate
: DEFAULT_RATE;
this.container.innerHTML = `${playbackRate}x`;
this.setAttribute('aria-label', nouns.PLAYBACK_RATE({ playbackRate }));
}
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget != undefined) return super.invokeTargetElement;
return getMediaController(this).querySelector('media-playback-rate-menu');
}
/**
* Will return a DOMTokenList.
* Setting a value will accept an array of numbers.
*/
get rates(): AttributeTokenList | number[] | undefined {
return this.#rates;
}
set rates(value: AttributeTokenList | number[] | undefined) {
if (!value) {
this.#rates.value = '';
} else if (Array.isArray(value)) {
this.#rates.value = value.join(' ');
}
}
/**
* The current playback rate
*/
get mediaPlaybackRate(): number {
return getNumericAttr(
this,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
DEFAULT_RATE
);
}
set mediaPlaybackRate(value: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, value);
}
}
if (!globalThis.customElements.get('media-playback-rate-menu-button')) {
globalThis.customElements.define(
'media-playback-rate-menu-button',
MediaPlaybackRateMenuButton
);
}
export { MediaPlaybackRateMenuButton };
export default MediaPlaybackRateMenuButton;

View File

@@ -0,0 +1,156 @@
import { globalThis } from '../utils/server-safe-globals.js';
import { MediaUIAttributes, MediaUIEvents } from '../constants.js';
import { AttributeTokenList } from '../utils/attribute-token-list.js';
import {
getNumericAttr,
setNumericAttr,
getMediaController,
} from '../utils/element-utils.js';
import { DEFAULT_RATES, DEFAULT_RATE } from '../media-playback-rate-button.js';
import {
MediaChromeMenu,
createMenuItem,
createIndicator,
} from './media-chrome-menu.js';
export const Attributes = {
RATES: 'rates',
};
/**
* @extends {MediaChromeMenu}
*
* @slot - Default slotted elements.
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
*
* @attr {string} rates - Set custom playback rates for the user to choose from.
* @attr {string} mediaplaybackrate - (read-only) Set to the media playback rate.
*/
class MediaPlaybackRateMenu extends MediaChromeMenu {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
Attributes.RATES,
];
}
#rates: AttributeTokenList = new AttributeTokenList(this, Attributes.RATES, {
defaultValue: DEFAULT_RATES,
});
constructor() {
super();
this.#render();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (
attrName === MediaUIAttributes.MEDIA_PLAYBACK_RATE &&
oldValue != newValue
) {
this.value = newValue;
} else if (attrName === Attributes.RATES && oldValue != newValue) {
this.#rates.value = newValue;
this.#render();
}
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('change', this.#onChange);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('change', this.#onChange);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor !== 'auto') return super.anchorElement;
return getMediaController(this).querySelector<HTMLElement>(
'media-playback-rate-menu-button'
);
}
/**
* Will return a DOMTokenList.
* Setting a value will accept an array of numbers.
*/
get rates(): AttributeTokenList | number[] | undefined {
return this.#rates;
}
set rates(value: AttributeTokenList | number[] | undefined) {
if (!value) {
this.#rates.value = '';
} else if (Array.isArray(value)) {
this.#rates.value = value.join(' ');
}
this.#render();
}
/**
* The current playback rate
*/
get mediaPlaybackRate(): number {
return getNumericAttr(
this,
MediaUIAttributes.MEDIA_PLAYBACK_RATE,
DEFAULT_RATE
);
}
set mediaPlaybackRate(value: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, value);
}
#render(): void {
this.defaultSlot.textContent = '';
for (const rate of this.rates) {
const item = createMenuItem({
type: 'radio',
text: this.formatMenuItemText(`${rate}x`, rate),
value: rate as string,
checked: this.mediaPlaybackRate == rate,
});
item.prepend(createIndicator(this, 'checked-indicator'));
this.defaultSlot.append(item);
}
}
#onChange(): void {
if (!this.value) return;
const event = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_PLAYBACK_RATE_REQUEST,
{
composed: true,
bubbles: true,
detail: this.value,
}
);
this.dispatchEvent(event);
}
}
if (!globalThis.customElements.get('media-playback-rate-menu')) {
globalThis.customElements.define(
'media-playback-rate-menu',
MediaPlaybackRateMenu
);
}
export { MediaPlaybackRateMenu };
export default MediaPlaybackRateMenu;

View File

@@ -0,0 +1,88 @@
import { MediaUIAttributes } from '../constants.js';
import { MediaChromeMenuButton } from './media-chrome-menu-button.js';
import { globalThis, document } from '../utils/server-safe-globals.js';
import { nouns, tooltipLabels } from '../labels/labels.js';
import {
getStringAttr,
setStringAttr,
getMediaController,
getNumericAttr,
setNumericAttr,
} from '../utils/element-utils.js';
const renditionIcon = /*html*/ `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M13.5 2.5h2v6h-2v-2h-11v-2h11v-2Zm4 2h4v2h-4v-2Zm-12 4h2v6h-2v-2h-3v-2h3v-2Zm4 2h12v2h-12v-2Zm1 4h2v6h-2v-2h-8v-2h8v-2Zm4 2h7v2h-7v-2Z" />
</svg>`;
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([aria-expanded="true"]) slot[name=tooltip] {
display: none;
}
</style>
<slot name="icon">${renditionIcon}</slot>
`;
/**
* @attr {string} mediarenditionselected - (read-only) Set to the selected rendition id.
* @attr {(unavailable|unsupported)} mediarenditionunavailable - (read-only) Set if rendition selection is unavailable.
*
* @cssproperty [--media-rendition-menu-button-display = inline-flex] - `display` property of button.
*/
class MediaRenditionMenuButton extends MediaChromeMenuButton {
static get observedAttributes() {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_RENDITION_SELECTED,
MediaUIAttributes.MEDIA_RENDITION_UNAVAILABLE,
MediaUIAttributes.MEDIA_HEIGHT,
];
}
constructor() {
super({ slotTemplate, tooltipContent: tooltipLabels.RENDITIONS });
}
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-label', nouns.QUALITY());
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget != undefined) return super.invokeTargetElement;
return getMediaController(this).querySelector('media-rendition-menu');
}
/**
* Get selected rendition id.
*/
get mediaRenditionSelected(): string {
return getStringAttr(this, MediaUIAttributes.MEDIA_RENDITION_SELECTED);
}
set mediaRenditionSelected(id: string) {
setStringAttr(this, MediaUIAttributes.MEDIA_RENDITION_SELECTED, id);
}
get mediaHeight(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_HEIGHT);
}
set mediaHeight(height: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_HEIGHT, height);
}
}
if (!globalThis.customElements.get('media-rendition-menu-button')) {
globalThis.customElements.define(
'media-rendition-menu-button',
MediaRenditionMenuButton
);
}
export { MediaRenditionMenuButton };
export default MediaRenditionMenuButton;

View File

@@ -0,0 +1,190 @@
import { globalThis } from '../utils/server-safe-globals.js';
import { MediaUIAttributes, MediaUIEvents } from '../constants.js';
import {
getMediaController,
getStringAttr,
setStringAttr,
getNumericAttr,
setNumericAttr,
} from '../utils/element-utils.js';
import { parseRenditionList } from '../utils/utils.js';
import {
MediaChromeMenu,
createMenuItem,
createIndicator,
} from './media-chrome-menu.js';
import { Rendition } from '../media-store/state-mediator.js';
/**
* @extends {MediaChromeMenu}
*
* @slot - Default slotted elements.
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
*
* @attr {string} mediarenditionselected - (read-only) Set to the selected rendition id.
* @attr {string} mediarenditionlist - (read-only) Set to the rendition list.
*/
class MediaRenditionMenu extends MediaChromeMenu {
static get observedAttributes(): string[] {
return [
...super.observedAttributes,
MediaUIAttributes.MEDIA_RENDITION_LIST,
MediaUIAttributes.MEDIA_RENDITION_SELECTED,
MediaUIAttributes.MEDIA_RENDITION_UNAVAILABLE,
MediaUIAttributes.MEDIA_HEIGHT,
];
}
#renditionList: Rendition[] = [];
#prevState: Record<string, any> = {};
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (
attrName === MediaUIAttributes.MEDIA_RENDITION_SELECTED &&
oldValue !== newValue
) {
this.value = newValue ?? 'auto';
} else if (
attrName === MediaUIAttributes.MEDIA_RENDITION_LIST &&
oldValue !== newValue
) {
this.#renditionList = parseRenditionList(newValue);
this.#render();
} else if (
attrName === MediaUIAttributes.MEDIA_HEIGHT &&
oldValue !== newValue
) {
this.#render();
}
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('change', this.#onChange);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('change', this.#onChange);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor !== 'auto') return super.anchorElement;
return getMediaController(this).querySelector<HTMLElement>(
'media-rendition-menu-button'
);
}
get mediaRenditionList(): Rendition[] {
return this.#renditionList;
}
set mediaRenditionList(list: Rendition[]) {
this.#renditionList = list;
this.#render();
}
/**
* Get selected rendition id.
*/
get mediaRenditionSelected(): string {
return getStringAttr(this, MediaUIAttributes.MEDIA_RENDITION_SELECTED);
}
set mediaRenditionSelected(id: string) {
setStringAttr(this, MediaUIAttributes.MEDIA_RENDITION_SELECTED, id);
}
get mediaHeight(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_HEIGHT);
}
set mediaHeight(height: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_HEIGHT, height);
}
#render(): void {
if (
this.#prevState.mediaRenditionList === JSON.stringify(this.mediaRenditionList) &&
this.#prevState.mediaHeight === this.mediaHeight
) return;
this.#prevState.mediaRenditionList = JSON.stringify(this.mediaRenditionList);
this.#prevState.mediaHeight = this.mediaHeight;
const renditionList = this.mediaRenditionList.sort(
(a: any, b: any) => b.height - a.height
);
for (const rendition of renditionList) {
// `selected` is not serialized in the rendition list because
// each selection would cause a re-render of the menu.
// @ts-ignore
rendition.selected = rendition.id === this.mediaRenditionSelected;
}
this.defaultSlot.textContent = '';
const isAuto = !this.mediaRenditionSelected;
for (const rendition of renditionList) {
const text = this.formatMenuItemText(
`${Math.min(rendition.width as number, rendition.height as number)}p`,
rendition
);
const item = createMenuItem({
type: 'radio',
text,
value: `${rendition.id}`,
checked: rendition.selected && !isAuto,
});
item.prepend(createIndicator(this, 'checked-indicator'));
this.defaultSlot.append(item);
}
const item = createMenuItem({
type: 'radio',
text: this.formatMenuItemText('Auto'),
value: 'auto',
checked: isAuto,
});
const autoDescription = this.mediaHeight > 0 ? `Auto (${this.mediaHeight}p)` : 'Auto';
item.dataset.description = autoDescription;
item.prepend(createIndicator(this, 'checked-indicator'));
this.defaultSlot.append(item);
}
#onChange(): void {
if (this.value == null) return;
const event = new globalThis.CustomEvent(
MediaUIEvents.MEDIA_RENDITION_REQUEST,
{
composed: true,
bubbles: true,
detail: this.value,
}
);
this.dispatchEvent(event);
}
}
if (!globalThis.customElements.get('media-rendition-menu')) {
globalThis.customElements.define('media-rendition-menu', MediaRenditionMenu);
}
export { MediaRenditionMenu };
export default MediaRenditionMenu;

View File

@@ -0,0 +1,55 @@
import { MediaChromeMenuButton } from './media-chrome-menu-button.js';
import { globalThis, document } from '../utils/server-safe-globals.js';
import { getMediaController } from '../utils/element-utils.js';
import { nouns, tooltipLabels } from '../labels/labels.js';
const slotTemplate: HTMLTemplateElement = document.createElement('template');
slotTemplate.innerHTML = /*html*/ `
<style>
:host([aria-expanded="true"]) slot[name=tooltip] {
display: none;
}
</style>
<slot name="icon">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M4.5 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm7.5 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm7.5 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/>
</svg>
</slot>
`;
/**
* @attr {string} target - CSS id selector for the element to be targeted by the button.
*/
class MediaSettingsMenuButton extends MediaChromeMenuButton {
static get observedAttributes(): string[] {
return [...super.observedAttributes, 'target'];
}
constructor() {
super({ slotTemplate, tooltipContent: tooltipLabels.SETTINGS });
}
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-label', nouns.SETTINGS());
}
/**
* Returns the element with the id specified by the `invoketarget` attribute.
* @return {HTMLElement | null}
*/
get invokeTargetElement(): HTMLElement | null {
if (this.invokeTarget != undefined) return super.invokeTargetElement;
return getMediaController(this).querySelector('media-settings-menu');
}
}
if (!globalThis.customElements.get('media-settings-menu-button')) {
globalThis.customElements.define(
'media-settings-menu-button',
MediaSettingsMenuButton
);
}
export { MediaSettingsMenuButton };
export default MediaSettingsMenuButton;

View File

@@ -0,0 +1,39 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { MediaChromeMenuItem } from './media-chrome-menu-item.js';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML =
MediaChromeMenuItem.template.innerHTML +
/*html*/ `
<style>
slot:not([name="submenu"]) {
opacity: var(--media-settings-menu-item-opacity, var(--media-menu-item-opacity));
}
:host([aria-expanded="true"]:hover) {
background: transparent;
}
</style>
`;
if (template.content?.querySelector) {
template.content.querySelector('slot[name="suffix"]').innerHTML = /*html*/ `
<svg aria-hidden="true" viewBox="0 0 20 24">
<path d="m8.12 17.585-.742-.669 4.2-4.665-4.2-4.666.743-.669 4.803 5.335-4.803 5.334Z"/>
</svg>
`;
}
class MediaSettingsMenuItem extends MediaChromeMenuItem {
static template = template;
}
if (!globalThis.customElements.get('media-settings-menu-item')) {
globalThis.customElements.define(
'media-settings-menu-item',
MediaSettingsMenuItem
);
}
export { MediaSettingsMenuItem };
export default MediaSettingsMenuItem;

View File

@@ -0,0 +1,58 @@
import { globalThis, document } from '../utils/server-safe-globals.js';
import { MediaChromeMenu } from './media-chrome-menu.js';
import { getMediaController } from '../utils/element-utils.js';
const template: HTMLTemplateElement = document.createElement('template');
// prettier-ignore
template.innerHTML = MediaChromeMenu.template.innerHTML + /*html*/`
<style>
:host {
background: var(--media-settings-menu-background,
var(--media-menu-background,
var(--media-control-background,
var(--media-secondary-color, rgb(20 20 30 / .8)))));
min-width: var(--media-settings-menu-min-width, 170px);
border-radius: 2px 2px 0 0;
overflow: hidden;
}
:host([role="menu"]) {
${/* Bottom fix setting menu items for animation when the height expands. */ ''}
justify-content: end;
}
slot:not([name]) {
justify-content: var(--media-settings-menu-justify-content);
flex-direction: var(--media-settings-menu-flex-direction, column);
overflow: visible;
}
#container.has-expanded {
--media-settings-menu-item-opacity: 0;
}
</style>
`;
/**
* @extends {MediaChromeMenu}
*
* @cssproperty --media-settings-menu-justify-content - `justify-content` of the menu.
*/
class MediaSettingsMenu extends MediaChromeMenu {
static template = template;
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor !== 'auto') return super.anchorElement;
return getMediaController(this).querySelector<HTMLElement>('media-settings-menu-button');
}
}
if (!globalThis.customElements.get('media-settings-menu')) {
globalThis.customElements.define('media-settings-menu', MediaSettingsMenu);
}
export { MediaSettingsMenu };
export default MediaSettingsMenu;

View File

@@ -0,0 +1,294 @@
import type { Context, ReactNode } from 'react';
import React, { createContext, useContext, useEffect, useMemo } from 'react';
import {
AvailabilityStates,
MediaUIEvents,
MediaUIProps,
StreamTypes,
VolumeLevels,
} from '../constants.js';
import createMediaStore, {
type MediaState,
type MediaStore,
} from '../media-store/media-store.js';
import type {
FullScreenElementStateOwner,
MediaStateOwner,
} from '../media-store/state-mediator.js';
import { useSyncExternalStoreWithSelector } from './useSyncExternalStoreWithSelector.js';
export * as timeUtils from '../utils/time.js';
/**
* @description A lookup object for all well-defined action types that can be dispatched
* to the `MediaStore`. As each action type name suggests, these all take the form of
* "state change requests," where e.g. a component will `dispatch()` a request to change
* some bit of media state, typically due to some user interaction.
*
* @example
* import { useDispatch, MediaActionTypes } from 'media-chrome/react/media-store';
*
* const MyComponent = () => {
* const dispatch = useDispatch();
* return (
* <button
* onClick={() => dispatch({
* type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
* detail: 2.0
* })}
* >
* Faster!
* </button>
* );
* };
*
* @see {@link useMediaDispatch}
*/
export { MediaState };
export { AvailabilityStates, StreamTypes, VolumeLevels };
const {
REGISTER_MEDIA_STATE_RECEIVER, // eslint-disable-line
UNREGISTER_MEDIA_STATE_RECEIVER, // eslint-disable-line
// NOTE: These generic state change requests are not currently supported (CJP)
MEDIA_SHOW_TEXT_TRACKS_REQUEST, // eslint-disable-line
MEDIA_HIDE_TEXT_TRACKS_REQUEST, // eslint-disable-line
...StateChangeRequests
} = MediaUIEvents;
export const MediaActionTypes = {
...StateChangeRequests,
MEDIA_ELEMENT_CHANGE_REQUEST: 'mediaelementchangerequest',
FULLSCREEN_ELEMENT_CHANGE_REQUEST: 'fullscreenelementchangerequest',
} as const;
export const MediaStateNames = { ...MediaUIProps } as const;
const identity = (x?: any) => x;
/**
* @description The {@link https://react.dev/learn/passing-data-deeply-with-context#context-an-alternative-to-passing-props|React Context}
* used "under the hood" for media ui state updates, state change requests, and the hooks and providers that integrate with this context.
* It is unlikely that you will/should be using `MediaContext` directly.
*
* @see {@link MediaProvider}
* @see {@link useMediaDispatch}
* @see {@link useMediaSelector}
*/
export const MediaContext: Context<MediaStore | null> =
createContext<MediaStore | null>(null);
/**
* @description A {@link https://react.dev/reference/react/createContext#provider|React Context.Provider} for having access
* to media state and state updates. While many other react libraries that rely on `<Provider/>` and its corresponding context/hooks
* are expected to have the context close to the top of the
* {@link https://react.dev/learn/understanding-your-ui-as-a-tree#the-render-tree|React render tree}, `<MediaProvider/>` should
* typically be declared closer to the component (e.g. `<MyFancyVideoPlayer/>`) level, as it manages the media state for a particular
* playback experience (visual and otherwise), typically tightly tied to e.g. an `<audio/>` or `<video/>` component (or similar).
* This state is tied together and managed by using {@link https://react.dev/learn/manipulating-the-dom-with-refs|DOM element Refs} to
* e.g. the corresponding `<video/>` element, which is made easy by our specialized hooks such as {@link useMediaRef}.
*
* @example
* import {
* MediaProvider,
* useMediaFullscreenRef,
* useMediaRef
* } from 'media-chrome/react/media-store';
* import MyFancyPlayButton from './MyFancyPlayButton';
*
* const MyFancyVideoPlayerContainer = ({ src }: { src: string }) => {
* const mediaFullscreenRef = useMediaFullscreenRef();
* const mediaRef = useMediaRef();
* return (
* <div ref={mediaFullscreenRef}>
* <video ref={mediaRef} src={src}/>
* <div><MyFancyPlayButton/><div>
* </div>
* );
* };
*
* const MyFancyVideoPlayer = ({ src }) => {
* return (
* <MediaProvider><MyFancyVideoPlayerContainer src={src}/></MediaProvider>
* );
* };
*
* export default MyFancyVideoPlayer;
*
* @see {@link useMediaRef}
* @see {@link useMediaFullscreenRef}
* @see {@link useMediaDispatch}
* @see {@link useMediaSelector}
*/
export const MediaProvider = ({
children,
mediaStore,
}: {
children: ReactNode;
mediaStore?: MediaStore;
}) => {
const value = useMemo(
() =>
mediaStore ?? createMediaStore({ documentElement: globalThis.document }),
[mediaStore]
);
useEffect(() => {
value?.dispatch({
type: 'documentelementchangerequest',
detail: globalThis.document,
});
return () => {
value?.dispatch({
type: 'documentelementchangerequest',
detail: undefined,
});
};
}, []);
return (
<MediaContext.Provider value={value}>{children}</MediaContext.Provider>
);
};
export const useMediaStore = () => {
const store = useContext(MediaContext);
return store;
};
/**
* @description This is a hook to get access to the `MediaStore`'s `dispatch()` method, which allows
* a component to make media state change requests. All player/application level state changes
* should use `dispatch()` to change media state (e.g. playing/pausing, enabling/disabling/selecting subtitles,
* changing playback rate, seeking, etc.). All well-defined state change request action types are defined in
* `MediaActionTypes`.
*
* @example
* import { useDispatch, MediaActionTypes } from 'media-chrome/react/media-store';
*
* // Assumes this is a descendant of `<MediaProvider/>`.
* const MyComponent = () => {
* const dispatch = useDispatch();
* return (
* <button
* onClick={() => dispatch({
* type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
* detail: 2.0
* })}
* >
* Faster!
* </button>
* );
* };
*
* @see {@link MediaActionTypes}
*/
export const useMediaDispatch = () => {
const store = useContext(MediaContext);
const dispatch = store?.dispatch ?? identity;
return ((value) => {
return dispatch(value);
}) as MediaStore['dispatch'];
};
/**
* @description This is the primary way to associate a media component with the `MediaStore` provided
* by {@link MediaProvider|`<MediaProvider/>`}. To associate the media component, use `useMediaRef` just
* like you would {@link https://react.dev/reference/react/useRef#manipulating-the-dom-with-a-ref|useRef}.
* Unlike `useRef`, however, "under the hood" `useMediaRef` is actually a
* {@link https://react.dev/reference/react-dom/components/common#ref-callback|ref callback} function.
*
* @example
* import type { VideoHTMLAttributes } from 'react';
* import { useMediaRef } from 'media-chrome/react/media-store';
*
* // Assumes this is a descendant of `<MediaProvider/>`.
* const VideoWrapper = (props: VideoHTMLAttributes<HTMLVideoElement>) => {
* const mediaRef = useMediaRef();
* return <video ref={mediaRef} {...props}/>;
* };
*
* @see {@link MediaProvider}
*/
export const useMediaRef = () => {
const dispatch = useMediaDispatch();
return (mediaEl: MediaStateOwner | null | undefined) => {
// NOTE: This should get invoked with `null` when using as a `ref` callback whenever
// the corresponding react media element instance (e.g. a `<video>`) is being removed.
dispatch({
type: MediaActionTypes.MEDIA_ELEMENT_CHANGE_REQUEST,
detail: mediaEl,
});
};
};
/**
* @description This is the primary way to associate a component with the `MediaStore` provided
* by {@link MediaProvider|`<MediaProvider/>`} to be used as the target for entering fullscreen.
* To associate the media component, use `useMediaFullscreenRef` just
* like you would {@link https://react.dev/reference/react/useRef#manipulating-the-dom-with-a-ref|useRef}.
* Unlike `useRef`, however, "under the hood" `useMediaFullscreenRef` is actually a
* {@link https://react.dev/reference/react-dom/components/common#ref-callback|ref callback} function.
*
* @example
* import { useMediaFullscreenRef } from 'media-chrome/react/media-store';
* import PlayerUI from './PlayerUI';
*
* // Assumes this is a descendant of `<MediaProvider/>`.
* const PlayerContainer = () => {
* const fullscreenRef = useMediaFullscreenRef();
* return <div ref={fullscreenRef}><PlayerUI/></div>;
* };
*
* @see {@link MediaProvider}
*/
export const useMediaFullscreenRef = () => {
const dispatch = useMediaDispatch();
return (fullscreenEl: FullScreenElementStateOwner | null | undefined) => {
// NOTE: This should get invoked with `null` when using as a `ref` callback whenever
// the corresponding react element instance (e.g. a `<div>`) is being removed.
dispatch({
type: MediaActionTypes.FULLSCREEN_ELEMENT_CHANGE_REQUEST,
detail: fullscreenEl,
});
};
};
const refEquality = (a: any, b: any) => a === b;
/**
* @description This is the primary way to get access to the media state. It accepts a function that let's you grab
* only the bit of state you care about to avoid unnecessary re-renders in react. It also allows you to pass in a more
* complex equality check (since you can transform the state or might only care about a subset of state changes, say only
* caring about second precision for time updates). Modeled after a simplified version of
* {@link https://redux.js.org/usage/deriving-data-selectors#encapsulating-state-shape-with-selectors|React Redux selectors}.
* @param selector - a function that gets invoked with the latest state and returns whatever computed state you want to use
* @param [equalityFn] - (optional) a function for checking if the previous computed state is "equal to" the next. Used to
* avoid unnecessary re-renders. Checks strict identity (===) by default.
* @returns the latest computed state
*
* @example
* import { useMediaSelector } from 'media-chrome/react/media-store';
*
* // Assumes this is a descendant of `<MediaProvider/>`.
* const LoadingIndicator = () => {
* const showLoading = useMediaSelector(state => state.mediaLoading && !state.mediaPaused);
* return showLoading && <div>Watch it, I'm loading, here! (...or don't, bc I'm loading, here!)</div>;
* };
*
* @see {@link MediaProvider}
*/
export const useMediaSelector = <S = any,>(
selector: (state: Partial<MediaState>) => S,
equalityFn = refEquality
) => {
const store = useContext(MediaContext) as MediaStore;
const selectedState = useSyncExternalStoreWithSelector(
store?.subscribe ?? identity,
store?.getState ?? identity,
store?.getState ?? identity,
selector,
equalityFn
) as S;
return selectedState;
};

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {
useRef,
useEffect,
useMemo,
useDebugValue,
useSyncExternalStore,
} from 'react';
// NOTE: This is a port of https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreWithSelector.js
// and React's internal/shared version of `Object.is` with refactors for TypeScript and bundling. Doing this to avoid adding an additional dependency
// beyond core React when using the `MediaStore` React hooks and related (CJP)
/**
* inlined Object.is polyfill to avoid requiring consumers ship their own
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function isPolyfill(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
type SnapshotRef<Selection> =
| {
hasValue: true;
value: Selection;
}
| {
hasValue: false;
value: null;
}
| null;
const is: (x: any, y: any) => boolean =
typeof Object.is === 'function' ? Object.is : isPolyfill;
// Same as useSyncExternalStore, but supports selector and isEqual arguments.
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: undefined | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean
) {
// Use this to track the rendered snapshot.
const instRef = useRef<SnapshotRef<Selection>>(null);
let inst: SnapshotRef<Selection>;
if (instRef.current === null) {
inst = {
hasValue: false,
value: null,
};
instRef.current = inst as SnapshotRef<Selection>;
} else {
inst = instRef.current;
}
const [getSelection, getServerSelection] = useMemo(() => {
// Track the memoized state using closure variables that are local to this
// memoized instance of a getSnapshot function. Intentionally not using a
// useRef hook, because that state would be shared across all concurrent
// copies of the hook/component.
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection: Selection;
const memoizedSelector = (nextSnapshot: Snapshot) => {
if (!hasMemo) {
// The first time the hook is called, there is no memoized result.
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined) {
// Even if the selector has changed, the currently rendered selection
// may be equal to the new selection. We should attempt to reuse the
// current value if possible, to preserve downstream memoizations.
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection;
return nextSelection;
}
// We may be able to reuse the previous invocation's result.
const prevSnapshot: Snapshot = memoizedSnapshot;
const prevSelection: Selection = memoizedSelection;
if (is(prevSnapshot, nextSnapshot)) {
// The snapshot is the same as last time. Reuse the previous selection.
return prevSelection;
}
// The snapshot has changed, so we need to compute a new selection.
const nextSelection = selector(nextSnapshot);
// If a custom isEqual function is provided, use that to check if the data
// has changed. If it hasn't, return the previous selection. That signals
// to React that the selections are conceptually equal, and we can bail
// out of rendering.
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
// Assigning this to a constant so that Flow knows it can't change.
const maybeGetServerSnapshot =
getServerSnapshot === undefined ? null : getServerSnapshot;
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector =
maybeGetServerSnapshot === null
? undefined
: () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection
);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}

View File

@@ -0,0 +1,15 @@
export class CustomElement extends HTMLElement {
static get observedAttributes() {
return [];
}
attributeChangedCallback(
attrName: string, // eslint-disable-line
oldValue: string | null, // eslint-disable-line
newValue: string | null // eslint-disable-line
) {}
connectedCallback(): void {}
disconnectedCallback(): void {}
}

View File

@@ -0,0 +1,3 @@
export type Entry<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];

View File

@@ -0,0 +1,4 @@
export type Point = {
x: number;
y: number;
};

View File

@@ -0,0 +1,6 @@
export type Rect = {
x: number;
y: number;
width: number;
height: number;
};

View File

@@ -0,0 +1,38 @@
import { TextTrackKinds } from '../constants.js';
export type TextTrackLike = {
/**
* The id of the track.
*/
id?: string;
/**
* Whether the track is enabled.
*/
enabled?: boolean;
/**
* A required kind for the track.
*/
kind: TextTrackKind | TextTrackKinds;
/**
* An optional label for the track.
*/
language?: string;
/**
* The BCP-47 compliant string representing the language code of the track
*/
label?: string;
/**
* The mode of the track.
*/
mode?: TextTrackMode;
/**
* The cue list for the track.
*/
cues?: TextTrackCueList;
};

View File

@@ -0,0 +1,111 @@
/* Adapted from floating-ui - The MIT License - Floating UI contributors */
import type { Point } from './Point.js';
import type { Rect } from './Rect.js';
export type PositionElements = {
anchor: HTMLElement;
floating: HTMLElement;
};
export type PositionRects = {
anchor: Rect;
floating: Rect;
};
export type Positions = PositionElements & {
placement: string;
};
export function computePosition({
anchor,
floating,
placement,
}: Positions): Point {
const rects = getElementRects({ anchor, floating });
const { x, y } = computeCoordsFromPlacement(rects, placement);
return { x, y };
}
function getElementRects({
anchor,
floating,
}: PositionElements): PositionRects {
return {
anchor: getRectRelativeToOffsetParent(anchor, floating.offsetParent),
floating: {
x: 0,
y: 0,
width: floating.offsetWidth,
height: floating.offsetHeight,
},
};
}
function getRectRelativeToOffsetParent(
element: Element,
offsetParent: Element
): Rect {
const rect = element.getBoundingClientRect();
// offsetParent returns null in the following situations:
// - The element or any ancestor has the display property set to none.
// - The element has the position property set to fixed (Firefox returns <body>).
// - The element is <body> or <html>.
const offsetRect = offsetParent?.getBoundingClientRect() ?? { x: 0, y: 0 };
return {
x: rect.x - offsetRect.x,
y: rect.y - offsetRect.y,
width: rect.width,
height: rect.height,
};
}
function computeCoordsFromPlacement(
{ anchor, floating }: PositionRects,
placement: string
): Rect {
const alignmentAxis = getSideAxis(placement) === 'x' ? 'y' : 'x';
const alignLength = alignmentAxis === 'y' ? 'height' : 'width';
const side = getSide(placement);
const commonX = anchor.x + anchor.width / 2 - floating.width / 2;
const commonY = anchor.y + anchor.height / 2 - floating.height / 2;
const commonAlign = anchor[alignLength] / 2 - floating[alignLength] / 2;
let coords;
switch (side) {
case 'top':
coords = { x: commonX, y: anchor.y - floating.height };
break;
case 'bottom':
coords = { x: commonX, y: anchor.y + anchor.height };
break;
case 'right':
coords = { x: anchor.x + anchor.width, y: commonY };
break;
case 'left':
coords = { x: anchor.x - floating.width, y: commonY };
break;
default:
coords = { x: anchor.x, y: anchor.y };
}
switch (placement.split('-')[1]) {
case 'start':
coords[alignmentAxis] -= commonAlign;
break;
case 'end':
coords[alignmentAxis] += commonAlign;
break;
}
return coords;
}
function getSide(placement: string): string {
return placement.split('-')[0];
}
function getSideAxis(placement: string): 'x' | 'y' {
return ['top', 'bottom'].includes(getSide(placement)) ? 'y' : 'x';
}

View File

@@ -0,0 +1,115 @@
export class AttributeTokenList
implements
Pick<
DOMTokenList,
| 'length'
| 'value'
| 'toString'
| 'item'
| 'add'
| 'remove'
| 'contains'
| 'toggle'
| 'replace'
>
{
#el: HTMLElement;
#attr: string;
#defaultSet: Set<string>;
#tokenSet: Set<string> = new Set<string>();
constructor(
el?: HTMLElement,
attr?: string,
{ defaultValue } = { defaultValue: undefined }
) {
this.#el = el;
this.#attr = attr;
this.#defaultSet = new Set(defaultValue);
}
[Symbol.iterator]() {
return this.#tokens.values();
}
get #tokens(): Set<string> {
return this.#tokenSet.size ? this.#tokenSet : this.#defaultSet;
}
get length(): number {
return this.#tokens.size;
}
get value(): string {
return [...this.#tokens].join(' ') ?? '';
}
set value(val: string) {
if (val === this.value) return;
this.#tokenSet = new Set();
this.add(...(val?.split(' ') ?? []));
}
toString(): string {
return this.value;
}
item(index): string {
return [...this.#tokens][index];
}
values(): Iterable<string> {
return this.#tokens.values();
}
forEach(
callback: (value: string, key: string, parent: Set<string>) => void,
thisArg?: any
) {
this.#tokens.forEach(callback, thisArg);
}
add(...tokens: string[]): void {
tokens.forEach((t) => this.#tokenSet.add(t));
// if the attribute was removed don't try to add it again.
if (this.value === '' && !this.#el?.hasAttribute(`${this.#attr}`)) {
return;
}
this.#el?.setAttribute(`${this.#attr}`, `${this.value}`);
}
remove(...tokens: string[]): void {
tokens.forEach((t) => this.#tokenSet.delete(t));
this.#el?.setAttribute(`${this.#attr}`, `${this.value}`);
}
contains(token: string): boolean {
return this.#tokens.has(token);
}
toggle(token: string, force: boolean): boolean {
if (typeof force !== 'undefined') {
if (force) {
this.add(token);
return true;
} else {
this.remove(token);
return false;
}
}
if (this.contains(token)) {
this.remove(token);
return false;
}
this.add(token);
return true;
}
replace(oldToken: string, newToken: string): boolean {
this.remove(oldToken);
this.add(newToken);
return oldToken === newToken;
}
}

View File

@@ -0,0 +1,267 @@
import { MediaUIAttributes, TextTrackKinds } from '../constants.js';
import type { TextTrackLike } from './TextTrackLike.js';
// NOTE: This is generic for any CSS/html list representation. Consider renaming and moving to generic module.
/**
* Splits a string (representing TextTracks) into an array of strings based on whitespace.
* @param textTracksStr - a string of 1+ "items" (representing TextTracks), separated by whitespace
* @returns An array of non-whitesace strings (each representing a single TextTrack).
*/
export const splitTextTracksStr = (textTracksStr = ''): string[] =>
textTracksStr.split(/\s+/);
/**
* Parses a string that represents a TextTrack into a "TextTrack-like object"
* The expected TextTrack string format is:
* "language[:label]"
* where the language *should* conform to BCP 47, just like TextTracks, and the (optional)
* label *must* be URL encoded.
* Note that this format may be expanded to include additional properties, such as
* `id`.
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack
* @param textTrackStr - A well-defined TextTrack string representations
* @returns An object that resembles a (partial) TextTrack (`{ language: string; label?: string; }`)
*/
export const parseTextTrackStr = (textTrackStr = ''): TextTrackLike => {
const [kind, language, encodedLabel] = textTrackStr.split(':');
const label = encodedLabel ? decodeURIComponent(encodedLabel) : undefined;
return {
kind: kind === 'cc' ? TextTrackKinds.CAPTIONS : TextTrackKinds.SUBTITLES,
language,
label,
};
};
/**
* Parses a whitespace-separated string that represents list of TextTracks into an array of TextTrack-like objects,
* where each object will have the properties identified by the corresponding string, plus any properties generically
* provided by the (optional) `textTrackLikeObj` argument.
* @param textTracksStr - a string of 1+ "items" (representing TextTracks), separated by whitespace
* @param textTrackLikeObj An object that resembles a (partial) TextTrack, used to add generic properties to all parsed TextTracks.
* @returns An array of "TextTrack-like objects", each with properties parsed from the string and any properties from `textTrackLikeObj`.
* @example
* ```js
* const tracksStr = 'en-US:English en:English%20%28with%20descriptions%29';
* const tracks = parseTextTracksStr(tracksStr);
* // [{ language: 'en-US', label: 'English' }, { language: 'en', label: 'English (with descriptions)' }];
*
* const tracksData = { kind: 'captions' };
* const tracksWithData = parseTextTracksStr(tracksStr, tracksData);
* // [{ language: 'en-US', label: 'English', kind: 'captions' }, { language: 'en', label: 'English (with descriptions)', kind: 'captions' }];
* ```
*/
export const parseTextTracksStr = (
textTracksStr = '',
textTrackLikeObj: Partial<TextTrackLike> = {}
): TextTrackLike[] => {
return splitTextTracksStr(textTracksStr).map((textTrackStr) => {
const textTrackObj = parseTextTrackStr(textTrackStr);
return {
...textTrackLikeObj,
...textTrackObj,
};
});
};
export type TrackOrTracks = string[] | TextTrackLike[] | string | TextTrackLike;
export type TextTrackListLike = TextTrackLike[] | TextTrackList;
/**
* Takes a variety of possible representations of TextTrack(s) and "normalizes" them to an Array of 1+ TextTrack-like objects.
* @param trackOrTracks - A value representing 1+ TextTracks
* @returns An array of TextTrack-like objects.
*/
export const parseTracks = (trackOrTracks: TrackOrTracks): TextTrackLike[] => {
if (!trackOrTracks) return [];
// Already an array, but might be an array of strings, objects, or both.
if (Array.isArray(trackOrTracks)) {
return trackOrTracks.map((trackObjOrStr) => {
// If the individual track is a string representation, translate it into a TextTrack-like object.
if (typeof trackObjOrStr === 'string') {
return parseTextTrackStr(trackObjOrStr);
}
// Otherwise, assume it already is one.
return trackObjOrStr;
});
}
// A string of 1+ TextTrack representations. Parse into an array of objects.
if (typeof trackOrTracks === 'string') {
return parseTextTracksStr(trackOrTracks);
}
// Assume a single TextTrack-like object. Wrap into an array of 1.
return [trackOrTracks];
};
/**
* Translates a TextTrack-like object into a well-defined string representation for the TextTrack
* @param obj - A TextTrack or TextTrack-like object
* @returns {string} A string representing a TextTrack with the format: "language[:label]"
*/
export const formatTextTrackObj = (
{ kind, label, language }: TextTrackLike = { kind: 'subtitles' }
): string => {
if (!label) return language;
return `${kind === 'captions' ? 'cc' : 'sb'}:${language}:${encodeURIComponent(
label
)}`;
};
/**
* Translates a set of TextTracks into a well-defined, whitespace-separated string representation of the set
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrackList
* @param textTracks - A TextTracks object or an Array of TextTracks or TextTrack-like objects.
* @returns A string representing a set of TextTracks, separated by whitespace.
*/
export const stringifyTextTrackList = (
textTracks: TextTrackListLike = []
): string => {
return Array.prototype.map.call(textTracks, formatTextTrackObj).join(' ');
};
// NOTE: This is a generic higher order fn. Consider and moving to generic module.
/**
* A generic higher-order function that yields a predicate to assert whether or not some value has the provided key/value pair
* @param key - The property key/name against which we'd like to match
* @param value - The value of the key we expect for a match
* @returns A predicate function that yields true if the provided object has the expected key/value pair, otherwise false.
* @example
* ```js
* const hasShowingMode = isMatchingPropOf('mode', 'showing');
* hasShowingMode({ mode: 'showing' }); // true
* hasShowingMode({ mode: 'disabled' }); // false
* hasShowingMode({ no_mode: 'any' }); // false
* ```
*/
export const isMatchingPropOf =
(key: string | number, value: any): ((value: any) => boolean) =>
(obj) =>
obj[key] === value;
// NOTE: This is a generic higher order fn. Consider renaming and moving to generic module.
/**
* A higher-order function that yields a single predicate to assert whether or not some value has *every* key/value pair defined in `filterObj`.
* @param filterObj - An object of key/value pairs that we expect on a given object
* @returns A predicate function that yields true iff the provided object has *every* key/value pair in `filterObj`, otherwise false
* @example
* ```js
* const track1 = { label: 'English', kind: 'captions', language: 'en-US' };
* const track1a = { label: 'English', kind: 'captions', language: 'en-US', id: '1', mode: 'showing' };
* const track2 = { label: 'English (with descriptions)', kind: 'captions', language: 'en-US', id: '2', mode: 'disabled' };
* const track3 = { label: 'Español', kind: 'subtitles', language: 'es-MX', id: '3', mode: 'disabled' };
* const track4 = { label: 'English', language: 'en-US', mode: 'showing' };
*
* const isMatchingTrack = textTrackObjAsPred({ label: 'English', kind: 'captions', language: 'en-US' });
* isMatchingTrack(track1); // true
* isMatchingTrack(track1a); // true
* isMatchingTrack(track2); // false
* isMatchingTrack(track3); // false
* isMatchingTrack(track4); // false
* isMatchingTrack({ no_corresponding_props: 'any' }); // false
* ```
*/
export const textTrackObjAsPred = (
filterObj: any
): ((textTrack: TextTrackLike) => boolean) => {
const preds = Object.entries(filterObj).map(([key, value]) => {
// Translate each key/value pair into a single predicate
return isMatchingPropOf(key, value);
});
// Return a predicate function that takes the array of single key/value pair predicates and asserts that *every* pred in the array is true of the (TextTrack-like) object
return (textTrack) => preds.every((pred) => pred(textTrack));
};
/**
* Updates any `tracks` that match one of the `tracksToUpdate` to be in the provided TextTrack `mode`.
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode
* @see {@link parseTracks}
* @param mode - The desired mode for any matching TextTracks. Should be one of "disabled"|"hidden"|"showing"
* @param tracks - A TextTracks object or array of TextTracks that should contain any matching TextTracks to update
* @param tracksToUpdate - A value representing a set of TextTracks
*/
export const updateTracksModeTo = (
mode: TextTrackMode,
tracks: TextTrackListLike = [],
tracksToUpdate: TrackOrTracks = []
) => {
// 1. Normalize the tracksToUpdate into an array of "partial TextTrack-like" objects
// 2. Convert each object into its own predicate function to identify that an actual TextTrack is a match
const preds = parseTracks(tracksToUpdate).map(textTrackObjAsPred);
// A track is identified as a track to update as long as it matches *one* of the preds (i.e. as long as it "looks like" one of "partial TextTrack-like" objects)
const isTrackToUpdate = (textTrack) => {
return preds.some((pred) => pred(textTrack));
};
Array.from(tracks as any)
// 1. Filter to only include tracks to update
.filter(isTrackToUpdate)
// 2. Update each of those tracks to the appropriate mode.
.forEach((textTrack: TextTrackLike) => {
textTrack.mode = mode;
});
};
export type TrackFilter = (track: TextTrackLike) => boolean;
/**
* Takes an `HTMLMediaElement media` and yields an array of `TextTracks` that match the provided `filterPredOrObj` criteria (or all `TextTracks` by default).
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/textTracks
* @see {@link textTrackObjAsPred}
* @param media - An HTMLMediaElement with an expected textTracks value
* (NOTE: This uses "structural polymorphism", so as long as `media` has an Array-like `textTracks` value of TextTrack-like objects, this function will work).
* @param filterPredOrObj - Either a predicate function or an object that can be translated into a predicate function of matching key/value pairs.
* @returns An array of TextTracks that match the given `filterPredOrObj` (or all TextTracks on `media` by default)
* @example
* ```html
* <!DOCTYPE html>
* <html lang="en">
* <head></head>
* <body>
* <video src="https://stream.mux.com/DS00Spx1CV902MCtPj5WknGlR102V5HFkDe/high.mp4">
* <track label="Spanish" kind="subtitles" srclang="es" src="./vtt/en-sub.vtt">
* <track label="English" kind="subtitles" srclang="en" src="./vtt/es-sub.vtt">
* <track label="English" kind="captions" srclang="en" src="./vtt/en-cc.vtt">
</video>
* </body>
* </html>
* ```
* ```js
* // js ...
* const media = document.querySelector('video');
* getTextTracksList(media, { kind: 'subtitles' });
* // [{ label: 'Spanish', kind: 'subtitles', language: 'es' }, { label: 'English', kind: 'subtitles', language: 'en' }]
* getTextTracksList(media, { kind: 'captions' });
* // [{ label: 'English', kind: 'captions', language: 'en' }]
* getTextTracksList(media);
* // [{ label: 'Spanish', kind: 'subtitles', language: 'es' }, { label: 'English', kind: 'subtitles', language: 'en' }, { label: 'English', kind: 'captions', language: 'en' }]
* ```
*/
export const getTextTracksList = (
media: HTMLVideoElement,
filterPredOrObj: TrackFilter | TextTrackLike = () => true
): TextTrackLike[] => {
if (!media?.textTracks) return [];
const filterPred =
typeof filterPredOrObj === 'function'
? filterPredOrObj
: textTrackObjAsPred(filterPredOrObj);
return (Array.from(media.textTracks) as TextTrackLike[]).filter(filterPred);
};
/**
* Are captions or subtitles enabled?
*
* @param el - An HTMLElement that has caption related attributes on it.
* @returns Whether captions are enabled or not
*/
export const areSubsOn = (
el: HTMLElement & { mediaSubtitlesShowing?: any[] }
): boolean => {
const showingSubtitles =
!!el.mediaSubtitlesShowing?.length ||
el.hasAttribute(MediaUIAttributes.MEDIA_SUBTITLES_SHOWING);
return showingSubtitles;
};

View File

@@ -0,0 +1,383 @@
import { MediaStateReceiverAttributes } from '../constants.js';
import type MediaController from '../media-controller.js';
/**
* Get the media controller element from the `mediacontroller` attribute or closest ancestor.
* @param host - The element to search for the media controller.
*/
export function getMediaController(
host: HTMLElement
): MediaController | undefined {
return (
getAttributeMediaController(host) ??
closestComposedNode(host, 'media-controller')
);
}
/**
* Get the media controller element from the `mediacontroller` attribute.
* @param host - The element to search for the media controller.
* @return
*/
export function getAttributeMediaController(
host: HTMLElement
): MediaController | undefined {
const { MEDIA_CONTROLLER } = MediaStateReceiverAttributes;
const mediaControllerId = host.getAttribute(MEDIA_CONTROLLER);
if (mediaControllerId) {
return getDocumentOrShadowRoot(host)?.getElementById(
mediaControllerId
) as MediaController;
}
}
export const updateIconText = (
svg: HTMLElement,
value: string,
selector: string = '.value'
): void => {
const node = svg.querySelector(selector);
if (!node) return;
node.textContent = value;
};
export const getAllSlotted = (
el: HTMLElement,
name: string
): HTMLCollection | HTMLElement[] => {
const slotSelector = `slot[name="${name}"]`;
const slot: HTMLSlotElement = el.shadowRoot.querySelector(slotSelector);
if (!slot) return [];
return slot.children;
};
export const getSlotted = (el: HTMLElement, name: string): HTMLElement =>
getAllSlotted(el, name)[0] as HTMLElement;
/**
*
* @param {{ contains?: Node['contains'] }} [rootNode]
* @param {Node} [childNode]
* @returns boolean
*/
export const containsComposedNode = (
rootNode: Node,
childNode: Node
): boolean => {
if (!rootNode || !childNode) return false;
if (rootNode?.contains(childNode)) return true;
return containsComposedNode(
rootNode,
(childNode.getRootNode() as ShadowRoot).host
);
};
export const closestComposedNode = <T extends Element = Element>(
childNode: Element,
selector: string
): T => {
if (!childNode) return null;
const closest = childNode.closest(selector);
if (closest) return closest as T;
return closestComposedNode(
(childNode.getRootNode() as ShadowRoot).host,
selector
);
};
/**
* Get the active element, accounting for Shadow DOM subtrees.
* @param root - The root node to search for the active element.
*/
export function getActiveElement(
root: Document | ShadowRoot = document
): HTMLElement {
const activeEl = root?.activeElement;
if (!activeEl) return null;
return getActiveElement(activeEl.shadowRoot) ?? (activeEl as HTMLElement);
}
/**
* Gets the document or shadow root of a node, not the node itself which can lead to bugs.
* https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode#return_value
* @param node - The node to get the root node from.
*/
export function getDocumentOrShadowRoot(
node: Node
): Document | ShadowRoot | null {
const rootNode = node?.getRootNode?.();
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
return rootNode;
}
return null;
}
/**
* Checks if the element is visible includes opacity: 0 and visibility: hidden.
* @param element - The element to check for visibility.
*/
export function isElementVisible(
element: HTMLElement,
{ depth = 3, checkOpacity = true, checkVisibilityCSS = true } = {}
): boolean {
// Supported by Chrome and Firefox https://caniuse.com/mdn-api_element_checkvisibility
// https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility
// @ts-ignore
if (element.checkVisibility) {
// @ts-ignore
return element.checkVisibility({
checkOpacity,
checkVisibilityCSS,
});
}
// Check if the element or its ancestors are hidden.
let el = element;
while (el && depth > 0) {
const style = getComputedStyle(el);
if (
(checkOpacity && style.opacity === '0') ||
(checkVisibilityCSS && style.visibility === 'hidden') ||
style.display === 'none'
) {
return false;
}
el = el.parentElement;
depth--;
}
return true;
}
export type Point = { x: number; y: number };
/**
* Get progress ratio of a point on a line segment.
* @param x - The x coordinate of the point.
* @param y - The y coordinate of the point.
* @param p1 - The first point of the line segment.
* @param p2 - The second point of the line segment.
*/
export function getPointProgressOnLine(
x: number,
y: number,
p1: Point,
p2: Point
): number {
const segment = distance(p1, p2);
const toStart = distance(p1, { x, y });
const toEnd = distance(p2, { x, y });
if (toStart > segment || toEnd > segment) {
// Point is outside the line segment, so clamp it to the nearest end
return toStart > toEnd ? 1 : 0;
}
return toStart / segment;
}
export function distance(p1: Point, p2: Point) {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}
/**
* Get or insert a CSSStyleRule with a selector in an element containing <style> tags.
* @param styleParent - The parent element containing <style> tags.
* @param selectorText - The selector text of the CSS rule.
* @return {CSSStyleRule | {
* style: {
* setProperty: () => void,
* removeProperty: () => void,
* width?: string,
* height?: string,
* display?: string,
* transform?: string,
* },
* selectorText: string,
* }}
*/
export function getOrInsertCSSRule(
styleParent: Element | ShadowRoot,
selectorText: string
): CSSStyleRule {
const cssRule = getCSSRule(styleParent, (st) => st === selectorText);
if (cssRule) return cssRule;
return insertCSSRule(styleParent, selectorText);
}
/**
* Get a CSSStyleRule with a selector in an element containing <style> tags.
* @param styleParent - The parent element containing <style> tags.
* @param predicate - A function that returns true for the desired CSSStyleRule.
*/
export function getCSSRule(
styleParent: Element | ShadowRoot,
predicate: (selectorText: string) => boolean
): CSSStyleRule | undefined {
let style;
for (style of styleParent.querySelectorAll('style:not([media])') ?? []) {
// Catch this error. e.g. browser extension adds style tags.
// Uncaught DOMException: CSSStyleSheet.cssRules getter:
// Not allowed to access cross-origin stylesheet
let cssRules;
try {
cssRules = style.sheet?.cssRules;
} catch {
continue;
}
for (const rule of cssRules ?? []) {
if (predicate(rule.selectorText)) return rule;
}
}
}
/**
* Insert a CSSStyleRule with a selector in an element containing <style> tags.
* @param styleParent - The parent element containing <style> tags.
* @param selectorText - The selector text of the CSS rule.
*/
export function insertCSSRule(
styleParent: Element | ShadowRoot,
selectorText: string
): CSSStyleRule | undefined {
const styles = styleParent.querySelectorAll('style:not([media])') ?? [];
const style = styles?.[styles.length - 1];
// If there is no style sheet return an empty style rule.
if (!style?.sheet) {
// The style tag must be connected to the DOM before it has a sheet.
// This could indicate a bug. Should the code be moved to connectedCallback?
console.warn(
'Media Chrome: No style sheet found on style tag of',
styleParent
);
return {
// @ts-ignore
style: {
setProperty: () => {},
removeProperty: () => '',
getPropertyValue: () => '',
},
};
}
style?.sheet.insertRule(`${selectorText}{}`, style.sheet.cssRules.length);
return /** @type {CSSStyleRule} */ style.sheet.cssRules?.[
style.sheet.cssRules.length - 1
];
}
/**
* Gets the number represented by the attribute
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to get
* @param defaultValue - The default value to return if the attribute is not set
* @returns Will return undefined if no attribute set
*/
export function getNumericAttr(
el: HTMLElement,
attrName: string,
defaultValue: number = Number.NaN
): number | undefined {
const attrVal = el.getAttribute(attrName);
return attrVal != null ? +attrVal : defaultValue;
}
/**
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to set
* @param value - The value to set
*/
export function setNumericAttr(
el: HTMLElement,
attrName: string,
value: number
): void {
// Simple cast to number
const nextNumericValue = +value;
// Treat null, undefined, and NaN as "nothing values", so unset if value is currently set.
if (value == null || Number.isNaN(nextNumericValue)) {
if (el.hasAttribute(attrName)) {
el.removeAttribute(attrName);
}
return;
}
// Avoid resetting a value that hasn't changed
if (getNumericAttr(el, attrName, undefined) === nextNumericValue) return;
el.setAttribute(attrName, `${nextNumericValue}`);
}
/**
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to get
*/
export function getBooleanAttr(el: HTMLElement, attrName: string): boolean {
return el.hasAttribute(attrName);
}
/**
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to set
* @param value - The value to set
*/
export function setBooleanAttr(
el: HTMLElement,
attrName: string,
value: boolean
): void {
// also handles undefined
if (value == null) {
if (el.hasAttribute(attrName)) {
el.removeAttribute(attrName);
}
return;
}
// avoid setting a value that hasn't changed
// NOTE: For booleans, we can rely on a loose equality check
if (getBooleanAttr(el, attrName) == value) return;
el.toggleAttribute(attrName, value);
}
/**
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to get
* @param defaultValue - The default value to return if the attribute is not set
*/
export function getStringAttr(
el: HTMLElement,
attrName: string,
defaultValue: any = null
) {
return el.getAttribute(attrName) ?? defaultValue;
}
/**
* @param el - (Should be an HTMLElement, but need any for SSR cases)
* @param attrName - The name of the attribute to get
* @param value - The value to set
*/
export function setStringAttr(
el: HTMLElement,
attrName: string,
value: string
) {
// also handles undefined
if (value == null) {
if (el.hasAttribute(attrName)) {
el.removeAttribute(attrName);
}
return;
}
const nextValue = `${value}`;
// avoid triggering a set if no change
if (getStringAttr(el, attrName, undefined) === nextValue) return;
el.setAttribute(attrName, nextValue);
}

View File

@@ -0,0 +1,47 @@
export type InvokeEventInit = EventInit & {
action?: string;
relatedTarget: Element;
};
/**
* Dispatch an InvokeEvent on the target element to perform an action.
* The default action is auto, which is determined by the target element.
* In our case it's only used for toggling a menu.
*/
export class InvokeEvent extends Event {
action: string;
relatedTarget: Element;
/**
* @param init - The event options.
*/
constructor({ action = 'auto', relatedTarget, ...options }: InvokeEventInit) {
super('invoke', options);
this.action = action;
this.relatedTarget = relatedTarget;
}
}
export type ToggleState = 'open' | 'closed';
export type ToggleEventInit = EventInit & {
newState: ToggleState;
oldState: ToggleState;
};
/**
* Similar to the popover toggle event.
* https://developer.mozilla.org/en-US/docs/Web/API/ToggleEvent
*/
export class ToggleEvent extends Event {
newState: ToggleState;
oldState: ToggleState;
/**
* @param init - The event options.
*/
constructor({ newState, oldState, ...options }: ToggleEventInit) {
super('toggle', options);
this.newState = newState;
this.oldState = oldState;
}
}

View File

@@ -0,0 +1,179 @@
import { WebkitPresentationModes } from '../constants.js';
import { containsComposedNode } from './element-utils.js';
import { document } from './server-safe-globals.js';
// 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'
: fullscreenElement && 'webkitRequestFullScreen' in fullscreenElement
? 'webkitRequestFullScreen'
: 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)
);
};

View File

@@ -0,0 +1,97 @@
import { globalThis, document } from './server-safe-globals.js';
import { delay } from './utils.js';
import { isFullscreenEnabled } from './fullscreen-api.js';
/**
* Test element
*/
let testMediaEl: HTMLVideoElement;
export const getTestMediaEl = (): HTMLVideoElement => {
if (testMediaEl) return testMediaEl;
testMediaEl = document?.createElement?.('video');
return testMediaEl;
};
/**
* Test for volume support
*
* @param mediaEl - The media element to test
*/
export const hasVolumeSupportAsync = async (
mediaEl: HTMLVideoElement = getTestMediaEl()
): Promise<boolean> => {
if (!mediaEl) return false;
const prevVolume = mediaEl.volume;
mediaEl.volume = prevVolume / 2 + 0.1;
// Remove event listeners later via an abort controller.
const abortController = new AbortController();
const volumeSupported = await Promise.race([
dispatchedVolumeChange(mediaEl, abortController.signal),
volumeChanged(mediaEl, prevVolume),
]);
abortController.abort();
return volumeSupported;
};
const dispatchedVolumeChange = (mediaEl: HTMLVideoElement, signal: AbortSignal) => {
// If the volumechange event is dispatched, it means the volume property is supported.
return new Promise<boolean>((resolve) => {
mediaEl.addEventListener('volumechange', () => resolve(true), { signal });
});
};
const volumeChanged = async (mediaEl: HTMLVideoElement, prevVolume: number) => {
// iOS Safari doesn't allow setting volume programmatically but it will
// change the volume property for a short time before reverting.
// On heavy sites this can take a while to revert so we need to wait at least
// 100ms to make sure the volume has not changed.
// If there is no change sooner, return false early to minimize UI jank.
for (let i = 0; i < 10; i++) {
if (mediaEl.volume === prevVolume) return false;
await delay(10);
}
return mediaEl.volume !== prevVolume;
};
// let volumeSupported;
// export const volumeSupportPromise = hasVolumeSupportAsync().then((supported) => {
// volumeSupported = supported;
// return volumeSupported;
// });
// NOTE: This also matches at least some non-Safari UAs on e.g. iOS, such as Chrome, perhaps since
// these browsers are built on top of the OS-level WebKit browser, so use accordingly (CJP).
// See, e.g.: https://www.whatismybrowser.com/guides/the-latest-user-agent/chrome
const isSafari: boolean = /.*Version\/.*Safari\/.*/.test(
globalThis.navigator.userAgent
);
/**
* Test for PIP support
*
* @param mediaEl - The media element to test
*/
export const hasPipSupport = (
mediaEl: HTMLVideoElement = getTestMediaEl()
): boolean => {
// NOTE: PWAs for Apple that rely on Safari don't support picture in picture but still have `requestPictureInPicture()`
// (which will result in a failed promise). Checking for those conditions here (CJP).
// This should still work for macOS PWAs installed using Chrome, where PiP is supported.
if (globalThis.matchMedia('(display-mode: standalone)').matches && isSafari)
return false;
return typeof mediaEl?.requestPictureInPicture === 'function';
};
/**
* Test for Fullscreen support
*
* @param mediaEl - The media element to test
*/
export const hasFullscreenSupport = (mediaEl = getTestMediaEl()) => {
return isFullscreenEnabled({ documentElement: document, media: mediaEl });
};
export const fullscreenSupported: boolean = hasFullscreenSupport();
export const pipSupported: boolean = hasPipSupport();
export const airplaySupported: boolean =
!!globalThis.WebKitPlaybackTargetAvailabilityEvent;
export const castSupported: boolean = !!globalThis.chrome;

View File

@@ -0,0 +1,87 @@
type Range = { valueAsNumber: number };
/**
* Smoothly animate a range input accounting for hiccups and diverging playback.
*/
export class RangeAnimation {
fps: number;
callback: (value: number) => void;
duration: number;
playbackRate: number;
#range: Range;
#startTime: number;
#previousTime: number;
#deltaTime: number;
#frameCount: number;
#updateTimestamp: number;
#updateStartValue: number;
#lastRangeIncrease: number;
#id = 0;
constructor(range: Range, callback: (value: number) => void, fps: number) {
this.#range = range;
this.callback = callback;
this.fps = fps;
}
start() {
if (this.#id !== 0) return;
this.#previousTime = performance.now();
this.#startTime = this.#previousTime;
this.#frameCount = 0;
this.#animate();
}
stop() {
if (this.#id === 0) return;
cancelAnimationFrame(this.#id);
this.#id = 0;
}
update({ start, duration, playbackRate }) {
// 1. Always allow increases.
// 2. Allow a relatively large decrease (user action or Safari jumping back :s).
const increase = start - this.#range.valueAsNumber;
const durationDelta = Math.abs(duration - this.duration);
if (increase > 0 || increase < -0.03 || durationDelta >= 0.5) {
this.callback(start);
}
this.#updateStartValue = start;
this.#updateTimestamp = performance.now();
this.duration = duration;
this.playbackRate = playbackRate;
}
#animate = (now = performance.now()) => {
this.#id = requestAnimationFrame(this.#animate);
this.#deltaTime = performance.now() - this.#previousTime;
const fpsInterval = 1000 / this.fps;
if (this.#deltaTime > fpsInterval) {
// Get ready for next frame by setting previousTime=now, but also adjust for your
// specified fpsInterval not being a multiple of RAF's interval (16.7ms)
this.#previousTime = now - (this.#deltaTime % fpsInterval);
const fps = 1000 / ((now - this.#startTime) / ++this.#frameCount);
const delta = (now - this.#updateTimestamp) / 1000 / this.duration;
let value = this.#updateStartValue + delta * this.playbackRate;
const increase = value - this.#range.valueAsNumber;
// If the increase is negative, the animation was faster than the playhead.
// Can happen on video startup. Slow down the animation to match the playhead.
if (increase > 0) {
// A perfect increase at this frame rate should be this much.
this.#lastRangeIncrease = this.playbackRate / this.duration / fps;
} else {
this.#lastRangeIncrease = 0.995 * this.#lastRangeIncrease;
value = this.#range.valueAsNumber + this.#lastRangeIncrease;
}
this.callback(value);
}
};
}

View File

@@ -0,0 +1,45 @@
import { globalThis } from './server-safe-globals.js';
// Use 1 resize observer instance for many elements for best performance.
// https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ
const callbacksMap = new WeakMap<Element, Set<ResizeCallback>>();
type ResizeCallback = (entry: ResizeObserverEntry) => void;
const getCallbacks = (element: Element): Set<ResizeCallback> => {
let callbacks = callbacksMap.get(element);
if (!callbacks)
callbacksMap.set(element, (callbacks = new Set<ResizeCallback>()));
return callbacks;
};
const observer = new globalThis.ResizeObserver(
(entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
for (const callback of getCallbacks(entry.target)) {
callback(entry);
}
}
}
);
export function observeResize(
element: Element,
callback: ResizeCallback
): void {
getCallbacks(element).add(callback);
observer.observe(element);
}
export function unobserveResize(
element: Element,
callback: ResizeCallback
): void {
const callbacks = getCallbacks(element);
callbacks.delete(callback);
if (!callbacks.size) {
observer.unobserve(element);
}
}

View File

@@ -0,0 +1,108 @@
class EventTarget {
addEventListener() { }
removeEventListener() { }
dispatchEvent() {
return true;
}
}
class Node extends EventTarget { }
class Element extends Node {
attributes: NamedNodeMap;
childNodes: NodeListOf<ChildNode>;
role: string | null = null;
}
class ResizeObserver {
observe() { }
unobserve() { }
disconnect() { }
}
const documentShim = {
createElement: function () {
return new globalThisShim.HTMLElement();
},
createElementNS: function () {
return new globalThisShim.HTMLElement();
},
addEventListener() { },
removeEventListener() { },
/**
*
* @param {Event} event
* @returns {boolean}
*/
dispatchEvent(event /* eslint-disable-line @typescript-eslint/no-unused-vars */) {
return false;
},
} as unknown as typeof globalThis['document'];
const globalThisShim = {
ResizeObserver,
document: documentShim,
Node,
Element,
HTMLElement: class HTMLElement extends Element {
innerHTML: string = '';
get content() {
return new globalThisShim.DocumentFragment();
}
},
DocumentFragment: class DocumentFragment extends EventTarget { },
customElements: {
get: function () { },
define: function () { },
whenDefined: function () { },
},
localStorage: {
/**
* @param {string} key
* @returns {string|null}
*/
getItem(key /* eslint-disable-line @typescript-eslint/no-unused-vars */) {
return null;
},
/**
* @param {string} key
* @param {string} value
*/
setItem(key, value) { }, // eslint-disable-line @typescript-eslint/no-unused-vars
/**
* @param {string} key
*/
removeItem(key) { }, // eslint-disable-line @typescript-eslint/no-unused-vars
},
CustomEvent: function CustomEvent() { },
getComputedStyle: function () { },
navigator: {
languages: [],
get userAgent() {
return '';
},
},
/**
* @param {string} media
*/
matchMedia(media) {
return {
matches: false,
media,
};
},
} as unknown as typeof globalThis;
export const isServer =
typeof window === 'undefined' || typeof window.customElements === 'undefined';
const isShimmed = Object.keys(globalThisShim).every((key) => key in globalThis);
export const GlobalThis: typeof globalThis =
isServer && !isShimmed ? globalThisShim : globalThis;
export const Document: typeof globalThis['document'] &
Partial<{
webkitExitFullscreen: typeof globalThis['document']['exitFullscreen'];
}> = isServer && !isShimmed ? documentShim : globalThis.document;
export { GlobalThis as globalThis, Document as document };

View File

@@ -0,0 +1,433 @@
import { globalThis } from '../utils/server-safe-globals.js';
/* Adapted from https://github.com/dy/template-parts - ISC - Dmitry Iv. */
// Template Instance API
// https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md
const ELEMENT = 1;
const STRING = 0;
const PART = 1;
export type State = Record<string, any>;
export type Parts = [string, Part][];
export type Processor = {
createCallback?: (
instance: TemplateInstance,
parts: Parts,
state: State
) => void;
processCallback: (
instance: TemplateInstance,
parts: Parts,
state: State
) => void;
};
export const defaultProcessor: Processor = {
processCallback(
instance: TemplateInstance,
parts: Parts,
state: State
): void {
if (!state) return;
for (const [expression, part] of parts) {
if (expression in state) {
const value = state[expression];
// boolean attr
if (
typeof value === 'boolean' &&
part instanceof AttrPart &&
typeof part.element[part.attributeName] === 'boolean'
) {
part.booleanValue = value;
} else if (typeof value === 'function' && part instanceof AttrPart) {
part.element[part.attributeName] = value;
} else {
part.value = value;
}
}
}
},
};
/**
*
*/
export class TemplateInstance extends globalThis.DocumentFragment {
#parts;
#processor: Processor;
constructor(
template: HTMLTemplateElement,
state?: State | null,
processor: Processor = defaultProcessor
) {
super();
this.append(template.content.cloneNode(true));
this.#parts = parse(this as any);
this.#processor = processor;
processor.createCallback?.(this, this.#parts, state);
processor.processCallback(this, this.#parts, state);
}
update(state?: State) {
this.#processor.processCallback(this, this.#parts, state);
}
}
// collect element parts
export const parse = (element: Element, parts: Parts = []) => {
let type, value;
for (const attr of element.attributes || []) {
if (attr.value.includes('{{')) {
const list = new AttrPartList();
for ([type, value] of tokenize(attr.value)) {
if (!type) list.append(value);
else {
const part = new AttrPart(element, attr.name, attr.namespaceURI);
list.append(part);
parts.push([value, part]);
}
}
attr.value = list.toString();
}
}
for (const node of element.childNodes) {
if (node.nodeType === ELEMENT && !(node instanceof HTMLTemplateElement)) {
parse(node as Element, parts);
} else {
const data = (node as any).data;
if (node.nodeType === ELEMENT || data.includes('{{')) {
const items = [];
if (data) {
for ([type, value] of tokenize(data))
if (!type) items.push(new Text(value));
else {
const part = new ChildNodePart(element);
items.push(part);
parts.push([value, part]);
}
} else if (node instanceof HTMLTemplateElement) {
const part = new InnerTemplatePart(element, node);
items.push(part);
parts.push([part.expression, part]);
}
node.replaceWith(
// @ts-ignore
...items.flatMap((part) => part.replacementNodes || [part])
);
}
}
}
return parts;
};
// parse string with template fields
const mem: Record<string, [number, string][]> = {};
export const tokenize = (text: string): [number, string][] => {
let value = '',
open = 0,
tokens = mem[text],
i = 0,
c;
if (tokens) return tokens;
else tokens = [];
for (; (c = text[i]); i++) {
if (
c === '{' &&
text[i + 1] === '{' &&
text[i - 1] !== '\\' &&
text[i + 2] &&
++open == 1
) {
if (value) tokens.push([STRING, value]);
value = '';
i++;
} else if (
c === '}' &&
text[i + 1] === '}' &&
text[i - 1] !== '\\' &&
!--open
) {
tokens.push([PART, value.trim()]);
value = '';
i++;
} else value += c || ''; // text[i] is undefined if i+=2 caught
}
if (value) tokens.push([STRING, (open > 0 ? '{{' : '') + value]);
return (mem[text] = tokens);
};
// DOM Part API
// https://github.com/WICG/webcomponents/blob/gh-pages/proposals/DOM-Parts.md
/*
Divergence from the proposal:
- Renamed AttributePart to AttrPart to match the existing class `Attr`.
- Renamed AttributePartGroup to AttrPartList as a group feels not ordered
while this collection should maintain its order. Also closer to DOMTokenList.
- A ChildNodePartGroup would make things unnecessarily difficult in this
implementation. Instead an empty text node keeps track of the ChildNodePart's
location in the child nodelist if needed.
*/
const FRAGMENT = 11;
export class Part {
get value(): string {
return '';
}
set value(val: string) {}
toString(): string {
return this.value;
}
}
const attrPartToList: WeakMap<AttrPart, AttrPartList> = new WeakMap();
type AttrPiece = AttrPart | string;
export class AttrPartList {
#items: AttrPiece[] = [];
[Symbol.iterator](): IterableIterator<AttrPiece> {
return this.#items.values();
}
get length(): number {
return this.#items.length;
}
item(index: number): AttrPiece {
return this.#items[index];
}
append(...items: AttrPiece[]): void {
for (const item of items) {
if (item instanceof AttrPart) {
attrPartToList.set(item, this);
}
this.#items.push(item);
}
}
toString(): string {
return this.#items.join('');
}
}
export class AttrPart extends Part {
#value: string = '';
#element: Element;
#attributeName: string;
#namespaceURI: string;
constructor(
element: Element,
attributeName: string,
namespaceURI: string | null
) {
super();
this.#element = element;
this.#attributeName = attributeName;
this.#namespaceURI = namespaceURI;
}
get #list(): AttrPartList {
return attrPartToList.get(this);
}
get attributeName(): string {
return this.#attributeName;
}
get attributeNamespace(): string {
return this.#namespaceURI;
}
get element(): Element {
return this.#element;
}
get value(): string {
return this.#value;
}
set value(newValue: string) {
if (this.#value === newValue) return; // save unnecessary call
this.#value = newValue;
if (!this.#list || this.#list.length === 1) {
// fully templatized
if (newValue == null) {
this.#element.removeAttributeNS(
this.#namespaceURI,
this.#attributeName
);
} else {
this.#element.setAttributeNS(
this.#namespaceURI,
this.#attributeName,
newValue
);
}
} else {
this.#element.setAttributeNS(
this.#namespaceURI,
this.#attributeName,
this.#list.toString()
);
}
}
get booleanValue(): boolean {
return this.#element.hasAttributeNS(
this.#namespaceURI,
this.#attributeName
);
}
set booleanValue(value: boolean) {
if (!this.#list || this.#list.length === 1) this.value = value ? '' : null;
else throw new DOMException('Value is not fully templatized');
}
}
export class ChildNodePart extends Part {
#parentNode;
#nodes;
constructor(parentNode: Element, nodes?: ChildNode[]) {
super();
this.#parentNode = parentNode;
this.#nodes = nodes ? [...nodes] : [new Text()];
}
get replacementNodes() {
return this.#nodes;
}
get parentNode() {
return this.#parentNode;
}
get nextSibling() {
return this.#nodes[this.#nodes.length - 1].nextSibling;
}
get previousSibling() {
return this.#nodes[0].previousSibling;
}
// FIXME: not sure why do we need string serialization here? Just because parent class has type DOMString?
get value() {
return this.#nodes.map((node) => node.textContent).join('');
}
set value(newValue) {
this.replace(newValue);
}
replace(...nodes) {
// replace current nodes with new nodes.
const normalisedNodes = nodes
.flat()
.flatMap((node) =>
node == null
? [new Text()]
: node.forEach
? [...node]
: node.nodeType === FRAGMENT
? [...node.childNodes]
: node.nodeType
? [node]
: [new Text(node)]
);
if (!normalisedNodes.length) normalisedNodes.push(new Text());
this.#nodes = swapdom(
this.#nodes[0].parentNode,
this.#nodes,
normalisedNodes,
this.nextSibling
);
}
}
export class InnerTemplatePart extends ChildNodePart {
directive: string;
expression: string;
template: HTMLTemplateElement;
constructor(parentNode: Element, template: HTMLTemplateElement) {
const directive =
template.getAttribute('directive') || template.getAttribute('type');
let expression =
template.getAttribute('expression') ||
template.getAttribute(directive) ||
'';
if (expression.startsWith('{{'))
expression = expression.trim().slice(2, -2).trim();
super(parentNode);
this.expression = expression;
this.template = template;
this.directive = directive;
}
}
function swapdom(parent, a, b, end = null) {
let i = 0,
cur,
next,
bi,
n = b.length,
m = a.length;
// skip head/tail
while (i < n && i < m && a[i] == b[i]) i++;
while (i < n && i < m && b[n - 1] == a[m - 1]) end = b[(--m, --n)];
// append/prepend/trim shortcuts
if (i == m) while (i < n) parent.insertBefore(b[i++], end);
if (i == n) while (i < m) parent.removeChild(a[i++]);
else {
cur = a[i];
while (i < n) {
(bi = b[i++]), (next = cur ? cur.nextSibling : end);
// skip
if (cur == bi) cur = next;
// swap / replace
else if (i < n && b[i] == next)
parent.replaceChild(bi, cur), (cur = next);
// insert
else parent.insertBefore(bi, cur);
}
// remove tail
while (cur != end)
(next = cur.nextSibling), parent.removeChild(cur), (cur = next);
}
return b;
}

View File

@@ -0,0 +1,332 @@
import {
AttrPart,
InnerTemplatePart,
Processor,
TemplateInstance,
type Part,
type State,
} from './template-parts.js';
import { isNumericString } from './utils.js';
// Filters concept like Nunjucks or Liquid.
const pipeModifiers = {
string: (value) => String(value),
};
class PartialTemplate {
template: any;
state: Record<string, any>;
constructor(template) {
this.template = template;
this.state = undefined;
}
}
const templates = new WeakMap();
const templateInstances = new WeakMap();
const Directives = {
partial: (part: any, state: any) => {
state[part.expression] = new PartialTemplate(part.template);
},
if: (part, state) => {
if (evaluateExpression(part.expression, state)) {
// If the template did not change for this part we can skip creating
// a new template instance / parsing and update the inner parts directly.
if (templates.get(part) !== part.template) {
templates.set(part, part.template);
const tpl = new TemplateInstance(part.template, state, processor);
part.replace(tpl);
templateInstances.set(part, tpl);
} else {
templateInstances.get(part)?.update(state);
}
} else {
part.replace('');
// Clean up template caches if this part's contents is cleared.
templates.delete(part);
templateInstances.delete(part);
}
},
};
const DirectiveNames = Object.keys(Directives);
export const processor: Processor = {
processCallback(
instance: TemplateInstance,
parts: [string, Part][],
state: State
) {
if (!state) return;
for (const [expression, part] of parts) {
if (part instanceof InnerTemplatePart) {
if (!part.directive) {
// Transform short-hand if/partial attributes to directive & expression.
const directive = DirectiveNames.find((n) =>
part.template.hasAttribute(n)
);
if (directive) {
part.directive = directive;
part.expression = part.template.getAttribute(directive);
}
}
Directives[part.directive]?.(part, state);
continue;
}
let value = evaluateExpression(expression, state);
if (value instanceof PartialTemplate) {
if (templates.get(part) !== value.template) {
templates.set(part, value.template);
value = new TemplateInstance(value.template, value.state, processor);
part.value = value;
templateInstances.set(part, value);
} else {
templateInstances.get(part)?.update(value.state);
}
continue;
}
if (value) {
if (part instanceof AttrPart) {
if (part.attributeName.startsWith('aria-')) {
value = String(value);
}
}
// No need to HTML escape values, the template parts stringify the values.
if (part instanceof AttrPart) {
if (typeof value === 'boolean') {
part.booleanValue = value;
} else if (typeof value === 'function') {
part.element[part.attributeName] = value;
} else {
part.value = value;
}
} else {
part.value = value;
// Clean up template caches if this part's contents is not a partial.
templates.delete(part);
templateInstances.delete(part);
}
} else {
if (part instanceof AttrPart) {
part.value = undefined;
} else {
part.value = undefined;
// Clean up template caches if this part's contents is cleared.
templates.delete(part);
templateInstances.delete(part);
}
}
}
},
};
const operators = {
'!': (a) => !a,
'!!': (a) => !!a,
'==': (a, b) => a == b,
'!=': (a, b) => a != b,
'>': (a, b) => a > b,
'>=': (a, b) => a >= b,
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
'??': (a, b) => a ?? b,
'|': (a, b) => pipeModifiers[b]?.(a),
};
export function tokenizeExpression(expr: string): Token[] {
return tokenize(expr, {
boolean: /true|false/,
number: /-?\d+\.?\d*/,
string: /(["'])((?:\\.|[^\\])*?)\1/,
operator: /[!=><][=!]?|\?\?|\|/,
ws: /\s+/,
param: /[$a-z_][$\w]*/i,
}).filter(({ type }) => type !== 'ws');
}
// Support minimal expressions e.g.
// >PlayButton section="center"
// section ?? 'bottom'
// value | string
// streamtype == 'on-demand'
// streamtype != 'live'
// breakpointmd
// !targetlivewindow
export function evaluateExpression(
expr: string,
state: Record<string, any> = {}
): any {
const tokens = tokenizeExpression(expr);
if (tokens.length === 0 || tokens.some(({ type }) => !type)) {
return invalidExpression(expr);
}
// e.g. {{>PlayButton section="center"}}
if (tokens[0]?.token === '>') {
const partial = state[tokens[1]?.token];
if (!partial) {
return invalidExpression(expr);
}
const partialState = { ...state };
partial.state = partialState;
// Adds support for arguments e.g. {{>PlayButton section="center"}}
const args = tokens.slice(2);
for (let i = 0; i < args.length; i += 3) {
const name = args[i]?.token;
const operator = args[i + 1]?.token;
const value = args[i + 2]?.token;
if (name && operator === '=') {
partialState[name] = getParamValue(value, state);
}
}
return partial;
}
// e.g. {{'hello world'}} or {{breakpointmd}}
if (tokens.length === 1) {
if (!isValidParam(tokens[0])) {
return invalidExpression(expr);
}
return getParamValue(tokens[0].token, state);
}
// e.g. {{!targetlivewindow}} or {{!!lengthInBoolean}}
if (tokens.length === 2) {
const operator = tokens[0]?.token;
const run = operators[operator];
if (!run || !isValidParam(tokens[1])) {
return invalidExpression(expr);
}
const a = getParamValue(tokens[1].token, state);
return run(a);
}
// e.g. {{streamtype == 'on-demand'}}, {{val | string}}, {{section ?? 'bottom'}}
if (tokens.length === 3) {
const operator = tokens[1]?.token;
const run = operators[operator];
if (!run || !isValidParam(tokens[0]) || !isValidParam(tokens[2])) {
return invalidExpression(expr);
}
const a = getParamValue(tokens[0].token, state);
if (operator === '|') {
return run(a, tokens[2].token);
}
const b = getParamValue(tokens[2].token, state);
return run(a, b);
}
}
function invalidExpression(expr: string): boolean {
console.warn(`Warning: invalid expression \`${expr}\``);
return false;
}
function isValidParam({ type }: Token): boolean {
return ['number', 'boolean', 'string', 'param'].includes(type);
}
// Eval params of something like `{{PlayButton param='center'}}
export function getParamValue(raw: string, state?: Record<string, any>) {
const firstChar = raw[0];
const lastChar = raw.slice(-1);
if (raw === 'true' || raw === 'false') {
// boolean
return raw === 'true';
}
if (firstChar === lastChar && [`'`, `"`].includes(firstChar)) {
// string
return raw.slice(1, -1);
}
if (isNumericString(raw)) {
// number
return parseFloat(raw);
}
// state property
return state[raw];
}
type Token = {
token: string;
type: string;
matches?: string[];
};
/*
* Tiny tokenizer
* https://gist.github.com/borgar/451393
*
* - Accepts a subject string and an object of regular expressions for parsing
* - Returns an array of token objects
*
* tokenize('this is text.', { word:/\w+/, whitespace:/\s+/, punctuation:/[^\w\s]/ });
* result => [{ token="this", type="word" },{ token=" ", type="whitespace" }, Object { token="is", type="word" }, ... ]
*
*/
function tokenize(str: string, parsers): Token[] {
let len, match, token;
const tokens: Token[] = [];
while (str) {
token = null;
len = str.length;
for (const key in parsers) {
match = parsers[key].exec(str);
// try to choose the best match if there are several
// where "best" is the closest to the current starting point
if (match && match.index < len) {
token = {
token: match[0],
type: key,
matches: match.slice(1),
};
len = match.index;
}
}
if (len) {
// there is text between last token and currently
// matched token - push that out as type: undefined
tokens.push({
token: str.substr(0, len),
type: undefined,
});
}
if (token) {
// push current token onto sequence
tokens.push(token);
}
str = str.substr(len + (token ? token.token.length : 0));
}
return tokens;
}

143
server/node_modules/media-chrome/src/js/utils/time.ts generated vendored Normal file
View File

@@ -0,0 +1,143 @@
import { isValidNumber } from './utils.js';
const UnitLabels = [
{
singular: 'hour',
plural: 'hours',
},
{
singular: 'minute',
plural: 'minutes',
},
{
singular: 'second',
plural: 'seconds',
},
];
const toTimeUnitPhrase = (timeUnitValue, unitIndex) => {
const unitLabel =
timeUnitValue === 1
? UnitLabels[unitIndex].singular
: UnitLabels[unitIndex].plural;
return `${timeUnitValue} ${unitLabel}`;
};
/**
* This function converts numeric seconds into a phrase
* @param {number} seconds - a (positive or negative) time, represented as seconds
* @returns {string} The time, represented as a phrase of hours, minutes, and seconds
*/
export const formatAsTimePhrase = (seconds) => {
if (!isValidNumber(seconds)) return '';
const positiveSeconds = Math.abs(seconds);
const negative = positiveSeconds !== seconds;
const secondsDateTime = new Date(0, 0, 0, 0, 0, positiveSeconds, 0);
const timeParts = [
secondsDateTime.getHours(),
secondsDateTime.getMinutes(),
secondsDateTime.getSeconds(),
];
// NOTE: Everything above should be useable for the `formatTime` function.
const timeString = timeParts
// Convert non-0 values to a string of the value plus its unit
.map(
(timeUnitValue, index) =>
timeUnitValue && toTimeUnitPhrase(timeUnitValue, index)
)
// Ignore/exclude any 0 values
.filter((x) => x)
// join into a single comma-separated string phrase
.join(', ');
// If the time was negative, assume it represents some remaining amount of time/"count down".
const negativeSuffix = negative ? ' remaining' : '';
return `${timeString}${negativeSuffix}`;
};
/**
* Converts a time, in numeric seconds, to a formatted string representation of the form [HH:[MM:]]SS, where hours and minutes
* are optional, either based on the value of `seconds` or (optionally) based on the value of `guide`.
*
* @param seconds - The total time you'd like formatted, in seconds
* @param guide - A number in seconds that represents how many units you'd want to show. This ensures consistent formatting between e.g. 35s and 4834s.
* @returns A string representation of the time, with expected units
*/
export function formatTime(seconds: number, guide?: number): string {
// Handle negative values at the end
let negative = false;
if (seconds < 0) {
negative = true;
seconds = 0 - seconds;
}
seconds = seconds < 0 ? 0 : seconds;
let s: number | string = Math.floor(seconds % 60);
let m: number | string = Math.floor((seconds / 60) % 60);
let h: number | string = Math.floor(seconds / 3600);
const gm = Math.floor((guide / 60) % 60);
const gh = Math.floor(guide / 3600);
// handle invalid times
if (isNaN(seconds) || seconds === Infinity) {
// '-' is false for all relational operators (e.g. <, >=) so this setting
// will add the minimum number of fields specified by the guide
h = m = s = '0';
}
// Check if we need to show hours
// @ts-ignore
h = h > 0 || gh > 0 ? h + ':' : '';
// If hours are showing, we may need to add a leading zero.
// Always show at least one digit of minutes.
// @ts-ignore
m = ((h || gm >= 10) && m < 10 ? '0' + m : m) + ':';
// Check if leading zero is need for seconds
// @ts-ignore
s = s < 10 ? '0' + s : s;
return (negative ? '-' : '') + h + m + s;
}
export const emptyTimeRanges: TimeRanges = Object.freeze({
length: 0,
start(index) {
const unsignedIdx = index >>> 0;
if (unsignedIdx >= this.length) {
throw new DOMException(
`Failed to execute 'start' on 'TimeRanges': The index provided (${unsignedIdx}) is greater than or equal to the maximum bound (${this.length}).`
);
}
return 0;
},
end(index) {
const unsignedIdx = index >>> 0;
if (unsignedIdx >= this.length) {
throw new DOMException(
`Failed to execute 'end' on 'TimeRanges': The index provided (${unsignedIdx}) is greater than or equal to the maximum bound (${this.length}).`
);
}
return 0;
},
});
/**
*/
export function serializeTimeRanges(
timeRanges: TimeRanges = emptyTimeRanges
): string {
return Array.from(timeRanges as any)
.map((_, i) =>
[
Number(timeRanges.start(i).toFixed(3)),
Number(timeRanges.end(i).toFixed(3)),
].join(':')
)
.join(' ');
}

102
server/node_modules/media-chrome/src/js/utils/utils.ts generated vendored Normal file
View File

@@ -0,0 +1,102 @@
import { TextTrackKinds } from '../constants.js';
import type { Rendition } from '../media-store/state-mediator';
import type { TextTrackLike } from './TextTrackLike.js';
export function stringifyRenditionList(renditions: Rendition[]): string {
return renditions?.map(stringifyRendition).join(' ');
}
export function parseRenditionList(renditions: string): Rendition[] {
return renditions?.split(/\s+/).map(parseRendition);
}
export function stringifyRendition(rendition: Rendition): string {
if (rendition) {
const { id, width, height } = rendition;
return [id, width, height].filter((a) => a != null).join(':');
}
}
export function parseRendition(rendition: string): Rendition {
if (rendition) {
const [id, width, height] = rendition.split(':');
return { id, width: +width, height: +height };
}
}
export function stringifyAudioTrackList(audioTracks: any[]) {
return audioTracks?.map(stringifyAudioTrack).join(' ');
}
export function parseAudioTrackList(audioTracks: string): TextTrackLike[] {
return audioTracks?.split(/\s+/).map(parseAudioTrack);
}
export function stringifyAudioTrack(audioTrack: any): string {
if (audioTrack) {
const { id, kind, language, label } = audioTrack;
return [id, kind, language, label].filter((a) => a != null).join(':');
}
}
export function parseAudioTrack(audioTrack: string): TextTrackLike {
if (audioTrack) {
const [id, kind, language, label] = audioTrack.split(':');
return {
id,
kind: kind as TextTrackKinds,
language,
label,
};
}
}
export function dashedToCamel(word: string): string {
return word
.split('-')
.map(function (x, i) {
return (
(i ? x[0].toUpperCase() : x[0].toLowerCase()) + x.slice(1).toLowerCase()
);
})
.join('');
}
export function constToCamel(
word: string,
upperFirst: boolean = false
): string {
return word
.split('_')
.map(function (x, i) {
return (
(i || upperFirst ? x[0].toUpperCase() : x[0].toLowerCase()) +
x.slice(1).toLowerCase()
);
})
.join('');
}
export function camelCase(name: string): string {
return name.replace(/[-_]([a-z])/g, ($0, $1) => $1.toUpperCase());
}
export function isValidNumber(x: any): boolean {
return typeof x === 'number' && !Number.isNaN(x) && Number.isFinite(x);
}
export function isNumericString(str: any): boolean {
if (typeof str != 'string') return false;
return !isNaN(str as any) && !isNaN(parseFloat(str));
}
/**
* Returns a promise that will resolve after passed ms.
* @param {number} ms
* @return {Promise}
*/
export const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const capitalize = (str: string) =>
str && str[0].toUpperCase() + str.slice(1);

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 26 24">
<path d="M22.13 3H3.87a.87.87 0 0 0-.87.87v13.26a.87.87 0 0 0 .87.87h3.4L9 16H5V5h16v11h-4l1.72 2h3.4a.87.87 0 0 0 .87-.87V3.87a.87.87 0 0 0-.86-.87Zm-8.75 11.44a.5.5 0 0 0-.76 0l-4.91 5.73a.5.5 0 0 0 .38.83h9.82a.501.501 0 0 0 .38-.83l-4.91-5.73Z"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 26 24">
<path d="M22.83 5.68a2.58 2.58 0 0 0-2.3-2.5c-3.62-.24-11.44-.24-15.06 0a2.58 2.58 0 0 0-2.3 2.5c-.23 4.21-.23 8.43 0 12.64a2.58 2.58 0 0 0 2.3 2.5c3.62.24 11.44.24 15.06 0a2.58 2.58 0 0 0 2.3-2.5c.23-4.21.23-8.43 0-12.64Zm-11.39 9.45a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.92 3.92 0 0 1 .92-2.77 3.18 3.18 0 0 1 2.43-1 2.94 2.94 0 0 1 2.13.78c.364.359.62.813.74 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.17 1.61 1.61 0 0 0-1.29.58 2.79 2.79 0 0 0-.5 1.89 3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.48 1.48 0 0 0 1-.37 2.1 2.1 0 0 0 .59-1.14l1.4.44a3.23 3.23 0 0 1-1.07 1.69Zm7.22 0a3.07 3.07 0 0 1-1.91.57 3.06 3.06 0 0 1-2.34-1 3.75 3.75 0 0 1-.92-2.67 3.88 3.88 0 0 1 .93-2.77 3.14 3.14 0 0 1 2.42-1 3 3 0 0 1 2.16.82 2.8 2.8 0 0 1 .73 1.31l-1.43.35a1.49 1.49 0 0 0-1.51-1.21 1.61 1.61 0 0 0-1.29.58A2.79 2.79 0 0 0 15 12a3 3 0 0 0 .49 1.93 1.61 1.61 0 0 0 1.27.58 1.44 1.44 0 0 0 1-.37 2.1 2.1 0 0 0 .6-1.15l1.4.44a3.17 3.17 0 0 1-1.1 1.7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 26 24">
<path d="M17.73 14.09a1.4 1.4 0 0 1-1 .37 1.579 1.579 0 0 1-1.27-.58A3 3 0 0 1 15 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34A2.89 2.89 0 0 0 19 9.07a3 3 0 0 0-2.14-.78 3.14 3.14 0 0 0-2.42 1 3.91 3.91 0 0 0-.93 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.17 3.17 0 0 0 1.07-1.74l-1.4-.45c-.083.43-.3.822-.62 1.12Zm-7.22 0a1.43 1.43 0 0 1-1 .37 1.58 1.58 0 0 1-1.27-.58A3 3 0 0 1 7.76 12a2.8 2.8 0 0 1 .5-1.85 1.63 1.63 0 0 1 1.29-.57 1.47 1.47 0 0 1 1.51 1.2l1.43-.34a2.81 2.81 0 0 0-.74-1.32 2.94 2.94 0 0 0-2.13-.78 3.18 3.18 0 0 0-2.43 1 4 4 0 0 0-.92 2.78 3.74 3.74 0 0 0 .92 2.66 3.07 3.07 0 0 0 2.34 1 3.07 3.07 0 0 0 1.91-.57 3.23 3.23 0 0 0 1.07-1.74l-1.4-.45a2.06 2.06 0 0 1-.6 1.07Zm12.32-8.41a2.59 2.59 0 0 0-2.3-2.51C18.72 3.05 15.86 3 13 3c-2.86 0-5.72.05-7.53.17a2.59 2.59 0 0 0-2.3 2.51c-.23 4.207-.23 8.423 0 12.63a2.57 2.57 0 0 0 2.3 2.5c1.81.13 4.67.19 7.53.19 2.86 0 5.72-.06 7.53-.19a2.57 2.57 0 0 0 2.3-2.5c.23-4.207.23-8.423 0-12.63Zm-1.49 12.53a1.11 1.11 0 0 1-.91 1.11c-1.67.11-4.45.18-7.43.18-2.98 0-5.76-.07-7.43-.18a1.11 1.11 0 0 1-.91-1.11c-.21-4.14-.21-8.29 0-12.43a1.11 1.11 0 0 1 .91-1.11C7.24 4.56 10 4.49 13 4.49s5.76.07 7.43.18a1.11 1.11 0 0 1 .91 1.11c.21 4.14.21 8.29 0 12.43Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

5
server/node_modules/media-chrome/src/svgs/cast.svg generated vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 28 24">
<path d="M3 18v3h3c0-1.7-1.34-3-3-3Zm0-4v2c2.76 0 5 2.2 5 5h2c0-3.87-3.13-7-7-7Z"/>
<path d="M3 10v2c4.97 0 9 4 9 9h2c0-6.08-4.93-11-11-11Z"/>
<path d="M23 3H5c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 28 24">
<path d="M3 18v3h3c0-1.7-1.34-3-3-3Zm0-4v2c2.76 0 5 2.2 5 5h2c0-3.87-3.13-7-7-7Z"/>
<path d="M3 10v2c4.97 0 9 4 9 9h2c0-6.08-4.93-11-11-11Z"/>
<path d="M23 3H5c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2Z"/>
<path d="M7 7v1.63C10 8.6 15.37 14 15.37 17H21V7H7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 20 24">
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Arial" font-size="8" font-weight="bold" letter-spacing="0em"><tspan x="8.9" y="19.8734">30</tspan></text>
<path d="M10 6V3l5.61 4L10 10.94V8a5.54 5.54 0 0 0-1.9 10.48v2.12A7.5 7.5 0 0 1 10 6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 26 24">
<path d="M16 3v2.5h3.5V9H22V3h-6ZM4 9h2.5V5.5H10V3H4v6Zm15.5 9.5H16V21h6v-6h-2.5v3.5ZM6.5 15H4v6h6v-2.5H6.5V15Z"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 26 24">
<path d="M18.5 6.5V3H16v6h6V6.5h-3.5ZM16 21h2.5v-3.5H22V15h-6v6ZM4 17.5h3.5V21H10v-6H4v2.5Zm3.5-11H4V9h6V3H7.5v3.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

3
server/node_modules/media-chrome/src/svgs/pause.svg generated vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="M6 20h4V4H6v16Zm8-16v16h4V4h-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 133 B

3
server/node_modules/media-chrome/src/svgs/pip.svg generated vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 28 24">
<path d="M24 3H4a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-1 16H5V5h18v14Zm-3-8h-7v5h7v-5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

3
server/node_modules/media-chrome/src/svgs/play.svg generated vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 21 15-9L6 3v18Z"/>
</svg>

After

Width:  |  Height:  |  Size: 120 B

3
server/node_modules/media-chrome/src/svgs/replay.svg generated vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 20 24">
<path d="M9 6V3L3.37 7 9 10.94V8a5.54 5.54 0 0 1 1.9 10.48v2.12A7.5 7.5 0 0 0 9 6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 20 24">
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Arial" font-size="8" font-weight="bold" letter-spacing="0em"><tspan x="2.18" y="19.8734">30</tspan></text>
<path d="M10 6V3L4.37 7 10 10.94V8a5.54 5.54 0 0 1 1.9 10.48v2.12A7.5 7.5 0 0 0 10 6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="M16.5 12A4.5 4.5 0 0 0 14 8v2.18l2.45 2.45a4.22 4.22 0 0 0 .05-.63Zm2.5 0a6.84 6.84 0 0 1-.54 2.64L20 16.15A8.8 8.8 0 0 0 21 12a9 9 0 0 0-7-8.77v2.06A7 7 0 0 1 19 12ZM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25A6.92 6.92 0 0 1 14 18.7v2.06A9 9 0 0 0 17.69 19l2 2.05L21 19.73l-9-9L4.27 3ZM12 4 9.91 6.09 12 8.18V4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 123 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4ZM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54Z"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B