Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/sandbox/templates/html-native-hls-video/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — HTML Video</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans p-2">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions apps/sandbox/templates/html-native-hls-video/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/native-hls-video';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';

const html = String.raw;

const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();

async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;

const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);

document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<native-hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</native-hls-video>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
</${tag}>
</video-player>
`;
}

render();

onSkinChange((skin) => {
state.skin = skin;
render();
});

onSourceChange((source) => {
state.source = source;
render();
});
14 changes: 14 additions & 0 deletions apps/sandbox/templates/react-native-hls-video/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — React Video</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans p-2">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
42 changes: 42 additions & 0 deletions apps/sandbox/templates/react-native-hls-video/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import '@app/styles.css';
import { VideoProvider } from '@app/shared/react/providers';
import { VideoSkinComponent } from '@app/shared/react/skins';
import { Storyboard } from '@app/shared/react/storyboard';
import { usePoster } from '@app/shared/react/use-poster';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { useStoryboard } from '@app/shared/react/use-storyboard';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { NativeHlsVideo } from '@videojs/react/media/native-hls-video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';

function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}

function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
const poster = usePoster();
const storyboard = useStoryboard();

return (
<VideoProvider>
<VideoSkinComponent
poster={poster}
skin={skin}
styling={styling}
className="w-full aspect-video max-w-4xl mx-auto"
>
<NativeHlsVideo src={SOURCES[source].url} playsInline crossOrigin="anonymous">
<Storyboard src={storyboard} />
</NativeHlsVideo>
</VideoSkinComponent>
</VideoProvider>
);
}

createRoot(document.getElementById('root')!).render(<App />);
14 changes: 2 additions & 12 deletions packages/core/src/core/media/delegate.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import type { Constructor } from '@videojs/utils/types';

import { bridgeEvents } from '../utils/bridge-events';
import { defineClassPropHooks } from '../utils/define-class-prop-hooks';

/** Wrap `source.dispatchEvent` so every event is also re-dispatched on `target`. */
export function bridgeEvents(source: EventTarget, target: EventTarget): void {
const origDispatch = source.dispatchEvent.bind(source);
source.dispatchEvent = (event: Event): boolean => {
const result = origDispatch(event);
target.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
return result;
};
}

export interface Delegate {
attach?(target: EventTarget): void;
detach?(): void;
Expand Down Expand Up @@ -70,8 +60,8 @@ export function DelegateMixin<Base extends Constructor<BaseType>, D extends Cons
}

attach(target: EventTarget): void {
super.attach?.(target);
this.#delegate.attach?.(target);
super.attach?.(target);
}

detach(): void {
Expand Down
42 changes: 27 additions & 15 deletions packages/core/src/core/media/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { AnyConstructor, Constructor } from '@videojs/utils/types';
import { defineClassPropHooks } from '../utils/define-class-prop-hooks';

export interface MediaProxy {
readonly target: EventTarget | null;
get(prop: keyof EventTarget): any;
set(prop: keyof EventTarget, val: any): void;
call(prop: keyof EventTarget, ...args: any[]): any;
attach(target: EventTarget): void;
detach(): void;
}

/**
* This mixin creates an API from the passed classes and proxies the methods and properties to the attached target.
*
Expand All @@ -16,8 +25,9 @@ export const ProxyMixin = <T extends EventTarget>(
PrimaryClass: AnyConstructor<T>,
...AdditionalClasses: AnyConstructor<EventTarget>[]
) => {
class MediaProxy {
class MediaProxyImpl extends EventTarget {
#target: EventTarget | null = null;
#types = new Set<string>();

get target() {
return this.#target;
Expand All @@ -41,10 +51,16 @@ export const ProxyMixin = <T extends EventTarget>(
attach(target: EventTarget): void {
if (!target || this.#target === target) return;
this.#target = target;
for (const type of this.#types) {
target.addEventListener(type, this.#forwardEvent);
}
}

detach(): void {
if (!this.#target) return;
for (const type of this.#types) {
this.#target.removeEventListener(type, this.#forwardEvent);
}
this.#target = null;
}

Expand All @@ -53,25 +69,21 @@ export const ProxyMixin = <T extends EventTarget>(
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void {
this.#target?.addEventListener(type, listener, options);
}
Comment thread
cursor[bot] marked this conversation as resolved.

removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions
): void {
this.#target?.removeEventListener(type, listener, options);
if (!this.#types.has(type)) {
this.#types.add(type);
this.#target?.addEventListener(type, this.#forwardEvent);
}
super.addEventListener(type, listener, options);
Comment thread
luwes marked this conversation as resolved.
}

dispatchEvent(event: Event): boolean {
Comment thread
cursor[bot] marked this conversation as resolved.
return this.#target?.dispatchEvent(event) ?? false;
}
#forwardEvent = (event: Event) => {
this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
};
}

for (const Class of [PrimaryClass, ...AdditionalClasses]) {
defineClassPropHooks(MediaProxy, Class.prototype);
defineClassPropHooks(MediaProxyImpl, Class.prototype);
}

return MediaProxy as unknown as Constructor<T>;
return MediaProxyImpl as unknown as Constructor<T & MediaProxy>;
};
31 changes: 31 additions & 0 deletions packages/core/src/core/media/tests/delegate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';

import { DelegateMixin } from '../delegate';
import { ProxyMixin } from '../proxy';

class FakeBase extends EventTarget {
get(_prop: string): any {}
Expand Down Expand Up @@ -58,4 +59,34 @@ describe('DelegateMixin', () => {
expect(handler).not.toHaveBeenCalled();
});
});

describe('attach order with ProxyMixin', () => {
it('delegate interceptor fires before proxy forwarder when listener added pre-attach', () => {
class InterceptingDelegate extends EventTarget {
attach(target: EventTarget): void {
target.addEventListener('error', (event) => {
event.stopImmediatePropagation();
this.dispatchEvent(new CustomEvent('error', { detail: 'enriched' }));
});
}
detach(): void {}
}

const ProxyBase = ProxyMixin(EventTarget);
const Mixed = DelegateMixin(ProxyBase, InterceptingDelegate);

const host = new Mixed();
const handler = vi.fn();
host.addEventListener('error', handler);

const target = new EventTarget();
host.attach(target);

target.dispatchEvent(new Event('error'));

expect(handler).toHaveBeenCalledOnce();
const event = handler.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toBe('enriched');
});
});
});
Loading
Loading