diff --git a/packages/app/src/components/PlayerTest.tsx b/packages/app/src/components/PlayerTest.tsx new file mode 100644 index 00000000..e288d7d4 --- /dev/null +++ b/packages/app/src/components/PlayerTest.tsx @@ -0,0 +1,28 @@ +import { HlsPlayer } from "@superstreamer/player/player"; +import { useEffect, useRef, useState } from "react"; + +interface PlayerTestProps { + url?: string | null; +} + +export function PlayerTest({ url }: PlayerTestProps) { + const ref = useRef(null); + const [player, setPlayer] = useState(null); + + useEffect(() => { + const player = new HlsPlayer(ref.current!); + setPlayer(player); + }, []); + + useEffect(() => { + if (!player || !url) { + return; + } + player.load(url); + return () => { + player.reset(); + }; + }, [player, url]); + + return
; +} diff --git a/packages/app/src/routes/(dashboard)/_layout/player.tsx b/packages/app/src/routes/(dashboard)/_layout/player.tsx index 7a173b25..a3b5e926 100644 --- a/packages/app/src/routes/(dashboard)/_layout/player.tsx +++ b/packages/app/src/routes/(dashboard)/_layout/player.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { CodeEditor } from "../../../components/CodeEditor"; import { Form } from "../../../components/Form"; -import { Player } from "../../../components/Player"; +import { PlayerTest } from "../../../components/PlayerTest"; import { useSwaggerSchema } from "../../../hooks/useSwaggerSchema"; import type { FormRef } from "../../../components/Form"; @@ -24,7 +24,7 @@ function RouteComponent() { return (
- +
= T extends { addEventListener: Handler } + ? T["addEventListener"] + : T extends { on: Handler } + ? T["on"] + : Handler; + +type RemoveCallback = T extends { + removeEventListener: Handler; +} + ? T["removeEventListener"] + : T extends { off: Handler } + ? T["off"] + : Handler; + +export class EventManager { + private bindings_ = new Set(); + + listen = (target: T) => + ((type, listener, context) => { + const binding = createBinding(target, type, listener, context); + this.bindings_.add(binding); + }) as AddCallback; + + listenOnce = (target: T) => + ((type, listener, context) => { + const binding = createBinding(target, type, listener, context, true); + this.bindings_.add(binding); + }) as AddCallback; + + unlisten = (target: T) => + ((type, listener) => { + const binding = Array.from(this.bindings_).find( + (binding) => + binding.target === target && + binding.type === type && + binding.listener === listener, + ); + if (binding) { + binding.remove(); + this.bindings_.delete(binding); + } + }) as RemoveCallback; + + removeAll() { + this.bindings_.forEach((binding) => { + binding.remove(); + }); + this.bindings_.clear(); + } +} + +/** + * Create a binding for a specific target. + * @param target + * @param type + * @param listener + * @param context + * @param once + * @returns + */ +function createBinding( + target: Target, + type: string, + listener: Handler, + context?: unknown, + once?: boolean, +) { + const methodMap = { + add: target.addEventListener?.bind(target) ?? target.on?.bind(target), + remove: + target.removeEventListener?.bind(target) ?? target.off?.bind(target), + }; + + const remove = () => { + methodMap.remove?.(type, callback); + }; + + const callback = async (...args: unknown[]) => { + try { + await listener.apply(context, args); + if (once) { + remove(); + } + } catch (error) { + console.error(error); + } + }; + + methodMap.add?.(type, callback); + + return { + target, + type, + listener, + context, + once, + remove, + }; +} + +type Binding = ReturnType; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Handler = (...args: any) => any; diff --git a/packages/player/src/player/index.ts b/packages/player/src/player/index.ts new file mode 100644 index 00000000..69f296ae --- /dev/null +++ b/packages/player/src/player/index.ts @@ -0,0 +1,72 @@ +import Hls from "hls.js"; +import { EventManager } from "./event-manager"; + +export class HlsPlayer { + private media_: HTMLMediaElement; + + private assetMedias_: [HTMLMediaElement, HTMLMediaElement]; + + private hlsMap_ = new Map(); + + private eventManager_ = new EventManager(); + + constructor(public container: HTMLDivElement) { + this.media_ = this.createMedia_(); + + this.assetMedias_ = [this.createMedia_(), this.createMedia_()]; + + this.setActiveMedia_(this.media_); + } + + private createMedia_() { + const media = document.createElement("video"); + this.container.appendChild(media); + + media.style.position = "absolute"; + media.style.inset = "0"; + media.style.width = "100%"; + media.style.height = "100%"; + + return media; + } + + load(url: string) { + const hls = new Hls(); + hls.attachMedia(this.media_); + + this.hlsMap_.set(this.media_, hls); + + this.bindListeners_(hls); + + hls.loadSource(url); + } + + reset() { + this.eventManager_.removeAll(); + + const hls = this.hlsMap_.get(this.media_); + if (hls) { + hls.destroy(); + this.hlsMap_.delete(this.media_); + } + } + + private bindListeners_(hls: Hls) { + const listen = this.eventManager_.listen(hls); + + listen(Hls.Events.MANIFEST_LOADED, () => { + console.log("LOADED IT"); + }); + + listen(Hls.Events.INTERSTITIAL_ASSET_PLAYER_CREATED, (event) => {}); + + listen(Hls.Events.INTERSTITIAL_ASSET_STARTED, () => {}); + } + + private setActiveMedia_(media: HTMLMediaElement) { + const allMedias = [this.media_, ...this.assetMedias_]; + allMedias.forEach((element) => { + element.style.opacity = element === media ? "1" : "0"; + }); + } +} diff --git a/packages/player/tsup.config.ts b/packages/player/tsup.config.ts index f009511b..878c765b 100644 --- a/packages/player/tsup.config.ts +++ b/packages/player/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: { index: "./src/facade/index.ts", react: "./src/react/index.tsx", + player: "./src/player/index.ts", }, splitting: false, sourcemap: true,