media-chrome
Version:
Custom elements (web components) for making audio and video player controls that look great in your website or app.
306 lines (288 loc) • 11.6 kB
text/typescript
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 isLive =
stateMediator.mediaStreamType.get(stateOwners) === StreamTypes.LIVE;
const canAutoSeekToLive = !stateOwners.options?.noAutoSeekToLive;
const isDVR = stateMediator.mediaTargetLiveWindow.get(stateOwners) > 0;
if (isLive && canAutoSeekToLive && !isDVR) {
const seekableEnd = stateMediator.mediaSeekable.get(stateOwners)?.[1];
// Only seek to live if we are live, not DVR, and have a known seekable end
if (seekableEnd) {
const seekToLiveOffset = stateOwners.options?.seekToLiveOffset ?? 0;
const liveEdgeTime = seekableEnd - seekToLiveOffset;
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 seekableEnd = 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(seekableEnd))) return;
const seekToLiveOffset = stateOwners.options?.seekToLiveOffset ?? 0;
const value = seekableEnd - seekToLiveOffset;
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);
},
};