Flatten workspace (#120)

This commit is contained in:
Rahim
2025-10-23 15:19:36 -07:00
committed by GitHub
parent e41f883aea
commit 1ad0bc436f
215 changed files with 804 additions and 6445 deletions
@@ -0,0 +1,27 @@
export interface CurrentTimeDisplayState {
/** The current time value in seconds */
currentTime: number | undefined;
/** The total duration in seconds (for future functionality) */
duration: number | undefined;
}
export const currentTimeDisplayStateDefinition = {
keys: ['currentTime', 'duration'] as const,
stateTransform: (rawState: Record<string, any>): CurrentTimeDisplayState => {
const { currentTime, duration } = rawState;
return {
currentTime,
duration,
};
},
createRequestMethods: (_dispatch: (action: { type: string; detail?: any }) => void) => ({}),
};
export type CurrentTimeDisplayStateDefinition = typeof currentTimeDisplayStateDefinition;
export type CurrentTimeDisplayComponentState = ReturnType<typeof currentTimeDisplayStateDefinition.stateTransform>;
export type CurrentTimeDisplayRequestMethods = ReturnType<
typeof currentTimeDisplayStateDefinition.createRequestMethods
>;
@@ -0,0 +1,24 @@
export interface DurationDisplayState {
/** The raw duration value in seconds */
duration: number | undefined;
}
export const durationDisplayStateDefinition = {
keys: ['duration'] as const,
stateTransform: (rawState: Record<string, any>): DurationDisplayState => {
const { duration } = rawState;
return {
duration,
};
},
createRequestMethods: (_dispatch: (action: { type: string; detail?: any }) => void) => ({}),
};
export type DurationDisplayStateDefinition = typeof durationDisplayStateDefinition;
export type DurationDisplayComponentState = ReturnType<typeof durationDisplayStateDefinition.stateTransform>;
export type DurationDisplayRequestMethods = ReturnType<typeof durationDisplayStateDefinition.createRequestMethods>;
@@ -0,0 +1,25 @@
export interface FullscreenButtonState {
fullscreen: boolean;
}
export interface FullscreenButtonMethods {
requestEnterFullscreen: () => void;
requestExitFullscreen: () => void;
}
export interface FullscreenButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => FullscreenButtonState;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => FullscreenButtonMethods;
}
export const fullscreenButtonStateDefinition: FullscreenButtonStateDefinition = {
keys: ['fullscreen'],
stateTransform: (rawState: any): FullscreenButtonState => ({
fullscreen: rawState.fullscreen ?? false,
}),
createRequestMethods: (dispatch): FullscreenButtonMethods => ({
requestEnterFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: true }),
requestExitFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: false }),
}),
};
@@ -0,0 +1,29 @@
export interface MuteButtonState {
muted: boolean;
volumeLevel: string;
}
export interface MuteButtonMethods {
requestMute: () => void;
requestUnmute: () => void;
}
export interface MuteButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => MuteButtonState;
createRequestMethods: (dispatch: (action: { type: string }) => void) => MuteButtonMethods;
}
export const muteButtonStateDefinition: MuteButtonStateDefinition = {
keys: ['muted', 'volumeLevel'],
stateTransform: (rawState: any): MuteButtonState => ({
muted: rawState.muted ?? false,
volumeLevel: rawState.volumeLevel ?? 'off',
}),
createRequestMethods: (dispatch): MuteButtonMethods => ({
requestMute: () => dispatch({ type: 'muterequest' }),
requestUnmute: () => dispatch({ type: 'unmuterequest' }),
}),
};
@@ -0,0 +1,27 @@
export interface PlayButtonState {
paused: boolean;
}
export interface PlayButtonMethods {
requestPlay: () => void;
requestPause: () => void;
}
export interface PlayButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => PlayButtonState;
createRequestMethods: (dispatch: (action: { type: string; detail?: unknown }) => void) => PlayButtonMethods;
}
export const playButtonStateDefinition: PlayButtonStateDefinition = {
keys: ['paused'],
stateTransform: (rawState: any): PlayButtonState => ({
paused: rawState.paused ?? true,
}),
createRequestMethods: (dispatch): PlayButtonMethods => ({
requestPlay: () => dispatch({ type: 'playrequest' }),
requestPause: () => dispatch({ type: 'pauserequest' }),
}),
};
@@ -0,0 +1,16 @@
export interface PreviewTimeDisplayState {
/** The preview time value in seconds */
previewTime: number | undefined;
}
export interface PreviewTimeDisplayStateDefinition {
keys: (keyof PreviewTimeDisplayState)[];
stateTransform: (rawState: any) => PreviewTimeDisplayState;
}
export const previewTimeDisplayStateDefinition: PreviewTimeDisplayStateDefinition = {
keys: ['previewTime'],
stateTransform: (rawState: any) => ({
previewTime: rawState.previewTime ?? 0,
}),
};
@@ -0,0 +1,33 @@
export interface TimeSliderState {
currentTime: number;
duration: number;
previewTime: number;
}
export interface TimeSliderMethods {
requestSeek: (time: number) => void;
requestPreview: (time: number) => void;
}
export interface TimeSliderStateDefinition {
keys: string[];
stateTransform: (rawState: any) => TimeSliderState;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => TimeSliderMethods;
}
export const timeSliderStateDefinition: TimeSliderStateDefinition = {
keys: ['currentTime', 'duration', 'previewTime'],
stateTransform: (rawState: any) => ({
currentTime: rawState.currentTime ?? 0,
duration: rawState.duration ?? 0,
previewTime: rawState.previewTime ?? 0,
}),
createRequestMethods: dispatch => ({
requestSeek: (time: number) => {
dispatch({ type: 'seekrequest', detail: time });
},
requestPreview: (time: number) => {
dispatch({ type: 'previewrequest', detail: time });
},
}),
};
@@ -0,0 +1,38 @@
export interface VolumeSliderState {
volume: number;
muted: boolean;
volumeLevel: 'high' | 'medium' | 'low' | 'off';
}
export interface VolumeSliderMethods {
requestVolumeChange: (volume: number) => void;
}
export interface VolumeSliderStateDefinition {
keys: string[];
stateTransform: (rawState: any) => VolumeSliderState;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => VolumeSliderMethods;
}
export const volumeSliderStateDefinition: VolumeSliderStateDefinition = {
keys: ['volume', 'muted', 'volumeLevel'],
stateTransform: (rawState: any) => ({
volume: rawState.volume ?? 1,
muted: rawState.muted ?? false,
volumeLevel: rawState.volumeLevel ?? 'high',
}),
createRequestMethods: dispatch => ({
/**
* @TODO Unmuting is owned by the "request-map" in media-chrome.
* The closest equivalent to that is the "actions" in the current architecture.
* Should unmuting live here (even if "here" gets promoted to the state model) or "actions" or state setter?
* Currently this is solved in the state setter (as is the corresponding unmute behavior). See mediators/audible for details. (CJP)
*/
requestVolumeChange: (volume: number) => {
// if (volume > 0) {
// dispatch({ type: 'unmuterequest' });
// }
dispatch({ type: 'volumerequest', detail: volume });
},
}),
};
+168
View File
@@ -0,0 +1,168 @@
import type { StateOwners } from './types';
import { getKey, map, subscribeKeys } from 'nanostores';
export interface EventOrAction<D = undefined> {
type: string;
detail?: D;
target?: EventTarget;
}
export type FacadeGetter<T, D = T> = (stateOwners: StateOwners, event?: EventOrAction<D>) => T;
export type FacadeSetter<T> = (value: T, stateOwners: StateOwners) => void;
export type StateOwnerUpdateHandler<T> = (
handler: (value?: T) => void,
stateOwners: StateOwners
) => (() => void) | void;
export interface ReadonlyFacadeProp<T, D = T> {
get: FacadeGetter<T, D>;
stateOwnersUpdateHandlers?: StateOwnerUpdateHandler<T>[];
}
export type FacadeProp<T, S = T, D = T> = ReadonlyFacadeProp<T, D> & {
set: FacadeSetter<S>;
/** @TODO We probably need to refactor this for more complex cases where we can't simply translate to a setter */
actions: {
[k: string]: (val: { type: string; detail?: unknown }) => ReturnType<FacadeGetter<T, D>>;
};
};
export interface StateMediator {
paused: FacadeProp<HTMLMediaElement['paused']>;
muted: FacadeProp<HTMLMediaElement['muted']>;
volume: FacadeProp<HTMLMediaElement['volume']>;
volumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>;
currentTime: FacadeProp<HTMLMediaElement['currentTime']>;
duration: ReadonlyFacadeProp<HTMLMediaElement['duration']>;
seekable: ReadonlyFacadeProp<[number, number] | undefined>;
previewTime: FacadeProp<number>;
fullscreen: FacadeProp<boolean>;
}
export interface MediaStore {
dispatch: (action: { type: string; detail?: unknown }) => void;
getState: () => any;
getKeys: (keys: string[]) => Record<string, any>;
subscribeKeys: (keys: string[], callback: (state: any) => void) => void;
subscribe: (callback: (state: any) => void) => void;
}
export function createMediaStore({
// media,
stateMediator,
}: {
media?: any;
container?: any;
stateMediator: Partial<StateMediator>;
}): MediaStore {
const stateOwners: StateOwners = {};
const store = map<any>({});
const stateUpdateHandlerCleanups: Record<string, (() => void)[]> = {};
const keys = Object.keys(stateMediator);
function updateStateOwners(nextStateOwners: any) {
// Check if any state owner has changed
const hasChanges = Object.entries(nextStateOwners).some(
([key, value]) => stateOwners[key as keyof StateOwners] !== value,
);
if (!hasChanges) {
return;
}
// Clean up existing handlers
Object.entries(stateUpdateHandlerCleanups).forEach(([stateName, cleanups]) => {
cleanups.forEach(cleanup => cleanup?.());
stateUpdateHandlerCleanups[stateName] = [];
});
Object.assign(stateOwners, nextStateOwners);
store.set(getInitialState(stateMediator, stateOwners));
// Set up new handlers
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
const { get, stateOwnersUpdateHandlers = [] } = stateObject;
if (!stateUpdateHandlerCleanups[stateName]) {
stateUpdateHandlerCleanups[stateName] = [];
}
// Create handler that updates the store
const updateHandler = (value?: any) => {
const nextValue = value !== undefined ? value : get(stateOwners);
store.setKey(stateName, nextValue);
};
// Execute each stateOwnersUpdateHandler
stateOwnersUpdateHandlers.forEach((setupHandler) => {
const cleanup = setupHandler(updateHandler, stateOwners);
if (typeof cleanup === 'function') {
stateUpdateHandlerCleanups[stateName]?.push(cleanup);
}
});
});
}
return {
dispatch(action: Pick<CustomEvent<any>, 'type' | 'detail'>): void {
const { type, detail } = action;
if (type === 'mediastateownerchangerequest') {
updateStateOwners({ media: detail });
} else if (type === 'containerstateownerchangerequest') {
updateStateOwners({ container: detail });
} else {
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
const { set, actions } = stateObject as FacadeProp<any, any, any>;
if (actions?.[type]) {
const actionFn = actions[type];
const actionValue = actionFn(action);
if (set) {
(set as FacadeSetter<any>)(actionValue, stateOwners);
} else {
// If there is no setter, store the action value in the store.
// Might need revisiting.
store.setKey(stateName, actionValue);
}
}
});
}
},
getState(): any {
return store.get();
},
getKeys(keys: string[]): Record<string, any> {
return keys.reduce(
(acc, k) => {
acc[k] = getKey(store, k);
return acc;
},
{} as { [k: string]: any },
);
},
subscribeKeys(keys: string[], callback: (state: any) => void): void {
subscribeKeys(store, keys, callback);
},
// NOTE: In the POC architecture using nano-stores, subscribe is simply subscribeKeys across all keys. (CJP)
subscribe(callback: (state: any) => void): void {
subscribeKeys(store, keys, callback);
},
};
}
function getInitialState(stateMediator: Partial<StateMediator>, stateOwners: any) {
const initialState: any = {};
for (const [stateName, { get }] of Object.entries(stateMediator)) {
if (!get) continue;
initialState[stateName] = get(stateOwners);
}
return initialState;
}
+15
View File
@@ -0,0 +1,15 @@
export * from './definitions/current-time-display';
export * from './definitions/duration-display';
export * from './definitions/fullscreen-button';
export * from './definitions/mute-button';
export * from './definitions/play-button';
export * from './definitions/preview-time-display';
export * from './definitions/time-slider';
export * from './definitions/volume-slider';
export * from './factory';
export { createMediaStore } from './media-store';
export * from './mediators/audible';
export * from './mediators/playable';
export * from './mediators/temporal';
+21
View File
@@ -0,0 +1,21 @@
import type { MediaStore } from './factory';
import { createMediaStore as factory } from './factory';
import { audible } from './mediators/audible';
import { fullscreenable } from './mediators/fullscreenable';
import { playable } from './mediators/playable';
import { preview } from './mediators/preview';
import { temporal } from './mediators/temporal';
// Example of default media store with default state mediator definitions. (CJP)
// NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP)
const stateMediator = { ...playable, ...audible, ...temporal, ...fullscreenable, ...preview };
type Params = Partial<Parameters<typeof factory>[0]>;
export function createMediaStore(params: Params = {}): MediaStore {
return factory({
stateMediator: stateMediator as any,
...params,
});
}
@@ -0,0 +1,87 @@
import type { StateOwners } from '../types';
export const audible = {
muted: {
get(stateOwners: StateOwners): boolean {
const { media } = stateOwners;
return media?.muted ?? false;
},
set(value: boolean, stateOwners: StateOwners): void {
const { media } = stateOwners;
if (!media) return;
media.muted = value;
if (!value && !media.volume) {
media.volume = 0.25;
}
},
stateOwnersUpdateHandlers: [
(handler: (value?: boolean) => void, stateOwners: StateOwners): (() => void) | void => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
},
] as const,
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API or aren't simple 1:1 with getter vs. setter (CJP) */
muterequest: () => true,
unmuterequest: () => false,
},
},
volume: {
get(stateOwners: StateOwners): number {
const { media } = stateOwners;
return media?.volume ?? 1.0;
},
set(value: number, stateOwners: StateOwners): void {
const { media } = stateOwners;
if (!media) return;
const numericValue = +value;
if (!Number.isFinite(numericValue)) return;
media.volume = numericValue;
if (numericValue > 0) {
media.muted = false;
}
},
stateOwnersUpdateHandlers: [
(handler: (value?: number) => void, stateOwners: StateOwners): (() => void) | void => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
},
] as const,
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
volumerequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
},
},
// NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP)
volumeLevel: {
get(stateOwners: StateOwners): 'high' | 'medium' | 'low' | 'off' {
const { media } = stateOwners;
if (typeof media?.volume == 'undefined') return 'high';
if (media.muted || media.volume === 0) return 'off';
if (media.volume < 0.5) return 'low';
if (media.volume < 0.75) return 'medium';
return 'high';
},
stateOwnersUpdateHandlers: [
(handler: (value?: 'high' | 'medium' | 'low' | 'off') => void, stateOwners: any): void | (() => void) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
},
] as const,
},
};
@@ -0,0 +1,132 @@
import { containsComposedNode } from '@vjs-10/utils/dom';
/** @TODO This is implemented for web/browser only! We will need an alternative state mediator model for e.g. React Native. (CJP) */
export const fullscreenable = {
fullscreen: {
get(stateOwners: any): boolean {
const { container } = stateOwners;
if (!container || !globalThis?.document) return false;
const doc = globalThis.document;
const currentFullscreenElement
= doc.fullscreenElement
|| (doc as any).webkitFullscreenElement
|| (doc as any).mozFullScreenElement
|| (doc as any).msFullscreenElement;
if (!currentFullscreenElement) return false;
// If document.fullscreenElement is the container, we're definitely in fullscreen
if (currentFullscreenElement === container) {
return true;
}
// Check if container is contained within the fullscreen element
if (currentFullscreenElement.contains?.(container)) {
return true;
}
// Handle web components with shadow DOM - traverse shadow DOM layers
// In this case (most modern browsers), the fullscreenElement may be
// a web component that contains our container within shadow DOM layers
if (currentFullscreenElement.localName?.includes('-')) {
let currentRoot = currentFullscreenElement.shadowRoot;
// Check if ShadowRoot supports fullscreenElement (Safari < 16.4 workaround)
const fullscreenElementKey
= 'fullscreenElement' in doc
? 'fullscreenElement'
: 'webkitFullscreenElement' in doc
? 'webkitFullscreenElement'
: undefined;
if (fullscreenElementKey && !(fullscreenElementKey in (currentRoot || {}))) {
// For older Safari versions, use composed node containment check
return containsComposedNode(currentFullscreenElement, container);
}
// Traverse shadow DOM layers looking for our container
if (fullscreenElementKey) {
while (currentRoot?.[fullscreenElementKey]) {
if (currentRoot[fullscreenElementKey] === container) return true;
if (currentRoot[fullscreenElementKey]?.contains?.(container)) return true;
currentRoot = currentRoot[fullscreenElementKey]?.shadowRoot;
}
}
}
return false;
},
set(value: boolean, stateOwners: any): void {
const { container } = stateOwners;
if (!container || !globalThis?.document) return;
try {
if (value) {
// Enter fullscreen
if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
// Safari support
container.webkitRequestFullscreen();
} else if (container.mozRequestFullScreen) {
// Firefox support
container.mozRequestFullScreen();
} else if (container.msRequestFullscreen) {
// IE/Edge support
container.msRequestFullscreen();
}
} else {
// Exit fullscreen
const doc = globalThis.document as any;
if (doc.exitFullscreen) {
doc.exitFullscreen();
} else if (doc.webkitExitFullscreen) {
// Safari support
doc.webkitExitFullscreen();
} else if (doc.mozCancelFullScreen) {
// Firefox support
doc.mozCancelFullScreen();
} else if (doc.msExitFullscreen) {
// IE/Edge support
doc.msExitFullscreen();
}
}
} catch (error) {
// Gracefully handle fullscreen API errors (e.g., user interaction required)
console.warn('Fullscreen operation failed:', error);
}
},
stateOwnersUpdateHandlers: [
(handler: (value?: boolean) => void, _stateOwners: any): void | (() => void) => {
if (!globalThis?.document) return;
const eventHandler = () => handler();
const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
events.forEach((event) => {
globalThis.document.addEventListener(event, eventHandler);
});
return () => {
events.forEach((event) => {
globalThis.document.removeEventListener(event, eventHandler);
});
};
},
] as const,
actions: {
/** Toggle fullscreen state or explicitly enter/exit based on detail */
fullscreenrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: undefined }): boolean => {
// If detail is provided, use it; otherwise toggle current state
if (typeof detail === 'boolean') {
return detail;
}
// Toggle behavior: check current fullscreen state
const fullscreenEl = globalThis?.document?.fullscreenElement;
return !fullscreenEl;
},
},
},
};
@@ -0,0 +1,5 @@
export { audible } from './audible';
export { fullscreenable } from './fullscreenable';
export { playable } from './playable';
export { preview } from './preview';
export { temporal } from './temporal';
@@ -0,0 +1,29 @@
export const playable = {
paused: {
get(stateOwners: any): boolean {
const { media } = stateOwners;
return media?.paused ?? true;
},
set(value: boolean, stateOwners: any): void {
const { media } = stateOwners;
media?.[value ? 'pause' : 'play']();
},
stateOwnersUpdateHandlers: [
(handler: (value?: boolean) => void, stateOwners: any): void | (() => void) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
const events = ['play', 'playing', 'pause', 'emptied'];
events.forEach(event => media.addEventListener(event, eventHandler));
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
},
] as const,
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
playrequest: () => false,
pauserequest: () => true,
},
},
};
@@ -0,0 +1,7 @@
export const preview = {
previewTime: {
actions: {
previewrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
},
},
};
@@ -0,0 +1,84 @@
import { isValidNumber } from '@vjs-10/utils';
export const temporal = {
currentTime: {
get(stateOwners: any): number {
const { media } = stateOwners;
return media?.currentTime ?? 0;
},
set(value: number, stateOwners: any): void {
const { media } = stateOwners;
if (!media || !isValidNumber(value)) return;
media.currentTime = value;
},
stateOwnersUpdateHandlers: [
(handler: (value?: number) => void, stateOwners: any): void | (() => void) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
const events = ['timeupdate', 'loadedmetadata'];
events.forEach(event => media.addEventListener(event, eventHandler));
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
},
] as const,
actions: {
/** @TODO Support more sophisticated seeking patterns like seek-to-live, relative seeking, etc. (CJP) */
seekrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
},
},
duration: {
get(stateOwners: any): number {
const { media } = stateOwners;
// Return 0 if no media or invalid duration
if (!media?.duration || Number.isNaN(media.duration) || !Number.isFinite(media.duration)) {
return 0;
}
return media.duration;
},
stateOwnersUpdateHandlers: [
(handler: (value?: number) => void, stateOwners: any): void | (() => void) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
const events = ['loadedmetadata', 'durationchange', 'emptied'];
events.forEach(event => media.addEventListener(event, eventHandler));
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
},
] as const,
},
seekable: {
get(stateOwners: any): [number, number] | undefined {
const { media } = stateOwners;
if (!media?.seekable?.length) return undefined;
const start = media.seekable.start(0);
const end = media.seekable.end(media.seekable.length - 1);
// Account for cases where metadata has an "empty" seekable range
if (!start && !end) return undefined;
return [Number(start.toFixed(3)), Number(end.toFixed(3))];
},
stateOwnersUpdateHandlers: [
(handler: (value?: [number, number] | undefined) => void, stateOwners: any): void | (() => void) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
const events = ['loadedmetadata', 'emptied', 'progress', 'seekablechange'];
events.forEach(event => media.addEventListener(event, eventHandler));
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
},
] as const,
},
};
+4
View File
@@ -0,0 +1,4 @@
export interface StateOwners {
media?: HTMLMediaElement;
container?: HTMLElement;
}