diff --git a/bun.lockb b/bun.lockb index e27253ac..806e5088 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/fixtures/media-chrome/index.html b/fixtures/media-chrome/index.html new file mode 100644 index 00000000..af805c5a --- /dev/null +++ b/fixtures/media-chrome/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/media-chrome/superstreamer-video-element.js b/fixtures/media-chrome/superstreamer-video-element.js new file mode 100644 index 00000000..d2a4e271 --- /dev/null +++ b/fixtures/media-chrome/superstreamer-video-element.js @@ -0,0 +1,283 @@ +import { Events, HlsPlayer } from "@superstreamer/player"; +import { MediaTracksMixin } from "media-tracks"; + +function getTemplateHTML() { + return ` + +
+ `; +} + +const symbolTrackId_ = Symbol("superstreamer.trackId"); + +class SuperstreamerVideoElement extends MediaTracksMixin( + globalThis.HTMLElement, +) { + static getTemplateHTML = getTemplateHTML; + + static shadowRootOptions = { + mode: "open", + }; + + static observedAttributes = ["src"]; + + #player; + + #readyState = 0; + + #video; + + constructor() { + super(); + + if (!this.shadowRoot) { + this.attachShadow({ + mode: "open", + }); + this.shadowRoot.innerHTML = getTemplateHTML(); + } + + const container = this.shadowRoot.querySelector(".container"); + this.#player = new HlsPlayer(container); + + this.#video = document.createElement("video"); + + this.#bindListeners(); + } + + #bindListeners() { + this.#player.on(Events.PLAYHEAD_CHANGE, () => { + switch (this.#player.playhead) { + case "play": + this.dispatchEvent(new Event("play")); + break; + case "playing": + this.dispatchEvent(new Event("playing")); + break; + case "pause": + this.dispatchEvent(new Event("pause")); + break; + } + }); + + this.#player.on(Events.TIME_CHANGE, () => { + this.dispatchEvent(new Event("timeupdate")); + }); + + this.#player.on(Events.VOLUME_CHANGE, () => { + this.dispatchEvent(new Event("volumechange")); + }); + + this.#player.on(Events.SEEKING_CHANGE, () => { + if (this.#player.seeking) { + this.dispatchEvent(new Event("seeking")); + } else { + this.dispatchEvent(new Event("seeked")); + } + }); + + this.#player.on(Events.READY, async () => { + this.#readyState = 1; + + this.dispatchEvent(new Event("loadedmetadata")); + this.dispatchEvent(new Event("durationchange")); + this.dispatchEvent(new Event("volumechange")); + this.dispatchEvent(new Event("loadcomplete")); + + this.#createVideoTracks(); + this.#createAudioTracks(); + this.#createTextTracks(); + }); + + this.#player.on(Events.STARTED, () => { + this.#readyState = 3; + }); + + this.#player.on(Events.ASSET_CHANGE, () => { + const controller = this.closest("media-controller"); + if (controller) { + controller.setAttribute("interstitial", this.#player.asset ? "1" : "0"); + } + }); + } + + get src() { + return this.getAttribute("src"); + } + + set src(val) { + if (this.src === val) { + return; + } + this.setAttribute("src", val); + } + + get textTracks() { + return this.#video.textTracks; + } + + attributeChangedCallback(attrName, oldValue, newValue) { + if (attrName === "src" && oldValue !== newValue) { + this.load(); + } + } + + async load() { + this.#readyState = 0; + + while (this.#video.firstChild) { + this.#video.firstChild.remove(); + } + + for (const videoTrack of this.videoTracks) { + this.removeVideoTrack(videoTrack); + } + for (const audioTrack of this.audioTracks) { + this.removeAudioTrack(audioTrack); + } + + this.#player.unload(); + + this.dispatchEvent(new Event("emptied")); + + if (this.src) { + this.dispatchEvent(new Event("loadstart")); + this.#player.load(this.src); + } + } + + get currentTime() { + return this.#player.time; + } + + set currentTime(val) { + this.#player.seekTo(val); + } + + get duration() { + return this.#player.duration; + } + + get paused() { + const { playhead } = this.#player; + if (playhead === "play" || playhead === "playing") { + return false; + } + return true; + } + + get readyState() { + return this.#readyState; + } + + get muted() { + return this.#player.volume === 0; + } + + set muted(val) { + this.#player.setVolume(val ? 0 : 1); + } + + get volume() { + return this.#player.volume; + } + + set volume(val) { + this.#player.setVolume(val); + } + + async play() { + this.#player.playOrPause(); + await Promise.resolve(); + } + + pause() { + this.#player.playOrPause(); + } + + #createVideoTracks() { + let videoTrack = this.videoTracks.getTrackById("main"); + + if (!videoTrack) { + videoTrack = this.addVideoTrack("main"); + videoTrack.id = "main"; + videoTrack.selected = true; + } + + this.#player.qualities.forEach((quality) => { + videoTrack.addRendition( + undefined, + quality.height, + quality.height, + undefined, + undefined, + ); + }); + + this.videoRenditions.addEventListener("change", (event) => { + if (event.target.selectedIndex < 0) { + this.#player.setQuality(null); + } else { + const rendition = this.videoRenditions[event.target.selectedIndex]; + this.#player.setQuality(rendition.height); + } + }); + } + + #createAudioTracks() { + this.#player.audioTracks.forEach((a) => { + const audioTrack = this.addAudioTrack("main", a.label, a.label); + audioTrack[symbolTrackId_] = a.id; + audioTrack.enabled = a.active; + }); + + this.audioTracks.addEventListener("change", () => { + const track = [...this.audioTracks].find((a) => a.enabled); + if (track) { + const id = track[symbolTrackId_]; + this.#player.setAudioTrack(id); + } + }); + } + + #createTextTracks() { + this.#player.subtitleTracks.forEach((s) => { + const textTrack = this.addTextTrack("subtitles", s.label, s.track.lang); + textTrack[symbolTrackId_] = s.id; + }); + + this.textTracks.addEventListener("change", () => { + const track = [...this.textTracks].find((t) => t.mode === "showing"); + if (track) { + const id = track[symbolTrackId_]; + this.#player.setSubtitleTrack(id); + } else { + this.#player.setSubtitleTrack(null); + } + }); + } + + addTextTrack(kind, label, language) { + const trackEl = document.createElement("track"); + trackEl.kind = kind; + trackEl.label = label; + trackEl.srclang = language; + trackEl.track.mode = "hidden"; + this.#video.append(trackEl); + return trackEl.track; + } +} + +if (!globalThis.customElements?.get("superstreamer-video")) { + globalThis.customElements.define( + "superstreamer-video", + SuperstreamerVideoElement, + ); +} + +export default SuperstreamerVideoElement; diff --git a/packages/app/index.html b/packages/app/index.html index 90fb7119..6e544183 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -4,6 +4,7 @@ Superstreamer +
diff --git a/packages/app/src/components/CodeEditor.tsx b/packages/app/src/components/CodeEditor.tsx index bf54c1f8..2d28233e 100644 --- a/packages/app/src/components/CodeEditor.tsx +++ b/packages/app/src/components/CodeEditor.tsx @@ -46,7 +46,6 @@ export function CodeEditor({ return ( {data}; -} diff --git a/packages/app/src/components/DataView.tsx b/packages/app/src/components/DataView.tsx new file mode 100644 index 00000000..e13ddc69 --- /dev/null +++ b/packages/app/src/components/DataView.tsx @@ -0,0 +1,143 @@ +interface DataViewProps { + data: object | string; + redacted?: string[]; +} + +type Value = string | number | null | undefined | object; + +export function DataView({ data, redacted }: DataViewProps) { + if (typeof data === "string") { + if (data.trim().startsWith("{")) { + data = JSON.parse(data); + } else { + return
{data}
; + } + } + + return ( +
+ +
+ ); +} + +function Value({ + name, + value, + parent, + redacted, +}: { + name: string; + value: Value | Value[]; + parent: string; + redacted?: string[]; +}) { + const path = [parent, name].filter((item) => !!item).join("."); + + if (redacted?.some((lookup) => matchRedacted(lookup, path))) { + return null; + } + + if (Array.isArray(value)) { + return ( + [index.toString(), child])} + parent={path} + redacted={redacted} + isArray + /> + ); + } + if (typeof value === "object" && value) { + const values = Object.entries(value); + + values.sort((a, b) => (a[0] > b[0] ? 1 : -1)); + + return ( + + ); + } + if (value !== Object(value)) { + return ( +
+ {name}: +
+ ); + } + return null; +} + +function IteratedValue({ + name, + values, + parent, + isArray, + redacted, +}: { + name: string; + values: [string, Value][]; + parent: string; + isArray?: boolean; + redacted?: string[]; +}) { + return ( +
+ {name ? `${name}: ` : ""}{" "} + {isArray ? ( + {"["} + ) : ( + {"{"} + )} + {values.map(([childName, child]) => { + return ( + + ); + })} + {isArray ? ( + {"]"} + ) : ( + {"}"} + )} +
+ ); +} + +function Primitive({ value }: { value: Value }) { + const str = String(value); + if (value === null || value === undefined) { + return {str}; + } + if (typeof value === "string") { + return "{str}"; + } + if (typeof value === "boolean") { + return {str}; + } + if (typeof value === "number") { + if (Number.isNaN(value)) { + return {str}; + } + return {str}; + } + return str; +} + +function matchRedacted(wildcard: string, str: string) { + const lookup = wildcard.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `^${lookup.replace(/\*/g, ".*").replace(/\?/g, ".")}$`, + "i", + ).test(str); +} diff --git a/packages/app/src/components/FilePreview.tsx b/packages/app/src/components/FilePreview.tsx index cb8afc20..f0b58cdb 100644 --- a/packages/app/src/components/FilePreview.tsx +++ b/packages/app/src/components/FilePreview.tsx @@ -1,6 +1,6 @@ import { Modal, ModalBody, ModalContent } from "@nextui-org/react"; import useSWR from "swr"; -import { DataDump } from "./DataDump"; +import { DataView } from "./DataView"; import { useAuth } from "../auth"; import type { StorageFile } from "@superstreamer/api/client"; @@ -50,7 +50,7 @@ export function FilePreview({ path, onClose }: FilePreviewProps) { function Preview({ file }: { file: StorageFile }) { if (file.mode === "payload") { - return ; + return ; } if (file.mode === "url") { if (file.type === "video") { diff --git a/packages/app/src/components/JobPage.tsx b/packages/app/src/components/JobPage.tsx index 88968885..dcaf6cd0 100644 --- a/packages/app/src/components/JobPage.tsx +++ b/packages/app/src/components/JobPage.tsx @@ -1,5 +1,5 @@ import { Card, CardBody, CardHeader } from "@nextui-org/react"; -import { DataDump } from "./DataDump"; +import { DataView } from "./DataView"; import { Format } from "./Format"; import { Logs } from "./Logs"; import type { Job } from "@superstreamer/api/client"; @@ -31,13 +31,13 @@ export function JobPage({ job, logs }: JobPageProps) { Input - + Output - + {job.outputData ? : null} diff --git a/packages/app/src/components/Player.tsx b/packages/app/src/components/Player.tsx index 5328e8a9..9790cbae 100644 --- a/packages/app/src/components/Player.tsx +++ b/packages/app/src/components/Player.tsx @@ -1,48 +1,34 @@ -import { - ControllerProvider, - Controls, - useController, -} from "@superstreamer/player/react"; -import Hls from "hls.js"; -import { useEffect, useState } from "react"; -import type { Lang, Metadata } from "@superstreamer/player/react"; +import { HlsPlayer } from "@superstreamer/player"; +import { useEffect, useRef } from "react"; +import { usePlayer } from "../context/PlayerContext"; interface PlayerProps { - url?: string | null; - metadata: Metadata; - lang: Lang; + url: string; } -export function Player({ url, lang, metadata }: PlayerProps) { - const [hls] = useState(() => new Hls()); - const controller = useController(hls, { - multipleVideoElements: false, - }); +export function Player({ url }: PlayerProps) { + const ref = useRef(null); + const { player, setPlayer } = usePlayer(); useEffect(() => { - if (url) { - hls.loadSource(url); - } - }, [url]); + const player = new HlsPlayer(ref.current!); + setPlayer(player); + + return () => { + setPlayer(null); + player.destroy(); + }; + }, []); useEffect(() => { - Object.assign(window, { - facade: controller.facade, - }); - }, [controller]); + if (!player || !url) { + return; + } + player.load(url); + return () => { + player.unload(); + }; + }, [player, url]); - return ( - -
-
-
- ); + return
; } diff --git a/packages/app/src/components/PlayerControls.tsx b/packages/app/src/components/PlayerControls.tsx new file mode 100644 index 00000000..b2f4e3de --- /dev/null +++ b/packages/app/src/components/PlayerControls.tsx @@ -0,0 +1,205 @@ +import { Button } from "@nextui-org/react"; +import cn from "clsx"; +import { useRef } from "react"; +import { Selection } from "./Selection"; +import { usePlayerSelector } from "../context/PlayerContext"; +import { useSeekbar } from "../hooks/useSeekbar"; +import type { ReactNode, RefObject } from "react"; + +export function PlayerControls() { + const ready = usePlayerSelector((player) => player.ready); + if (!ready) { + return null; + } + return ( +
+
+ +
+ +
+ ); +} + +function PlayButton() { + const playOrPause = usePlayerSelector((player) => player.playOrPause); + const playing = usePlayerSelector( + (player) => player.playhead === "play" || player.playhead === "playing", + ); + + return ( + + ); +} + +function Seekbar() { + const seekableStart = usePlayerSelector((player) => player.seekableStart); + const time = usePlayerSelector((player) => player.time); + const duration = usePlayerSelector((player) => player.duration); + const seekTo = usePlayerSelector((player) => player.seekTo); + + const seekbar = useSeekbar({ + min: seekableStart, + max: duration, + onSeeked: seekTo, + }); + + const percentage = (time - seekableStart) / (duration - seekableStart); + + return ( +
+ + {hms(seekbar.value)} + +
+
+
+
+
+ +
+ ); +} + +function Tooltip({ + x, + seekbarRef, + visible, + children, +}: { + x: number; + seekbarRef: RefObject; + visible: boolean; + children: ReactNode; +}) { + const ref = useRef(null); + + if (ref.current && seekbarRef.current) { + const seekbarRect = seekbarRef.current.getBoundingClientRect(); + const rect = ref.current.getBoundingClientRect(); + const offset = rect.width / 2 / seekbarRect.width; + if (x < offset) { + x = offset; + } else if (x > 1 - offset) { + x = 1 - offset; + } + } + + return ( +
+ {children} +
+ ); +} + +function CuePoints() { + const cuePoints = usePlayerSelector((player) => player.cuePoints); + const duration = usePlayerSelector((player) => player.duration); + const seekableStart = usePlayerSelector((player) => player.seekableStart); + + return ( +
+ {cuePoints.map((cuePoint) => { + return ( +
+
+
+ ); + })} +
+ ); +} + +function Time() { + const time = usePlayerSelector((player) => player.time); + const seekableStart = usePlayerSelector((player) => player.seekableStart); + const duration = usePlayerSelector((player) => player.duration); + const live = usePlayerSelector((player) => player.live); + + return ( +
+ {hms(time)} +
+ {live ? `${hms(seekableStart)} - ${hms(duration)}` : `${hms(duration)}`} +
+ ); +} + +function Tracks() { + const audioTracks = usePlayerSelector((player) => player.audioTracks); + const setAudioTrack = usePlayerSelector((player) => player.setAudioTrack); + + const subtitleTracks = usePlayerSelector((player) => player.subtitleTracks); + const setSubtitleTrack = usePlayerSelector( + (player) => player.setSubtitleTrack, + ); + + return ( +
+ item.active} + getKey={(item) => item.id} + getLabel={(item) => item.label} + onChange={(item) => setAudioTrack(item.id)} + /> + item.active), + }, + ]} + getActive={(item) => item.active} + label="Subtitles" + getKey={(item) => item.id} + getLabel={(item) => item.label} + onChange={(item) => setSubtitleTrack(item.id)} + /> +
+ ); +} + +function hms(seconds: number) { + return ( + new Date(seconds * 1000).toUTCString().match(/(\d\d:\d\d:\d\d)/)?.[0] ?? + "00:00:00" + ); +} diff --git a/packages/app/src/components/PlayerStats.tsx b/packages/app/src/components/PlayerStats.tsx new file mode 100644 index 00000000..89249ceb --- /dev/null +++ b/packages/app/src/components/PlayerStats.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { DataView } from "./DataView"; +import { usePlayer } from "../context/PlayerContext"; + +export function PlayerStats() { + const { player } = usePlayer(); + const [state, setState] = useState({}); + + useEffect(() => { + if (!player) { + return; + } + + const onUpdate = () => { + const state = extractPublicProps(player); + setState(state); + }; + + player.on("*", onUpdate); + onUpdate(); + + return () => { + player.off("*", onUpdate); + }; + }, [player]); + + return ( + + ); +} + +function extractPublicProps(value: object) { + if (!("__proto__" in value)) { + return {}; + } + + const descriptors = Object.entries( + Object.getOwnPropertyDescriptors(value.__proto__), + ); + + const state = descriptors + // Get public getters only + .filter(([name, desc]) => !name.endsWith("_") && desc.get) + .map(([name]) => name) + // Grab each property by name and add it to an object + .reduce((acc, name) => { + // @ts-expect-error Named properties + acc[name] = player[name]; + return acc; + }, {}); + + return state; +} diff --git a/packages/app/src/components/ScrollCard.tsx b/packages/app/src/components/ScrollCard.tsx new file mode 100644 index 00000000..e105056b --- /dev/null +++ b/packages/app/src/components/ScrollCard.tsx @@ -0,0 +1,14 @@ +import { Card } from "@nextui-org/react"; +import type { ReactNode } from "react"; + +interface ScrollCardProps { + children: ReactNode; +} + +export function ScrollCard({ children }: ScrollCardProps) { + return ( + +
{children}
+
+ ); +} diff --git a/packages/app/src/components/Selection.tsx b/packages/app/src/components/Selection.tsx new file mode 100644 index 00000000..e2067536 --- /dev/null +++ b/packages/app/src/components/Selection.tsx @@ -0,0 +1,50 @@ +import { Select, SelectItem } from "@nextui-org/react"; + +interface SelectionProps { + items: T[]; + label: string; + getKey(item: T): number | string | null; + getLabel(item: T): string; + getActive(item: T): boolean; + onChange(item: T): void; +} + +export function Selection({ + items, + label, + getKey, + getLabel, + getActive, + onChange, +}: SelectionProps) { + const selectedKeys: string[] = []; + for (const item of items) { + if (getActive(item)) { + const key = getKey(item); + selectedKeys.push(String(key)); + } + } + + return ( + + ); +} diff --git a/packages/app/src/components/Uniqolor.tsx b/packages/app/src/components/Uniqolor.tsx index 8b266931..ca46cfc9 100644 --- a/packages/app/src/components/Uniqolor.tsx +++ b/packages/app/src/components/Uniqolor.tsx @@ -24,11 +24,11 @@ export function Uniqolor({ value }: UniqolorProps) { function hexToRGB(hex: string, alpha: number) { return ( "rgba(" + - parseInt(hex.slice(1, 3), 16) + + Number.parseInt(hex.slice(1, 3), 16) + ", " + - parseInt(hex.slice(3, 5), 16) + + Number.parseInt(hex.slice(3, 5), 16) + ", " + - parseInt(hex.slice(5, 7), 16) + + Number.parseInt(hex.slice(5, 7), 16) + ", " + alpha + ")" diff --git a/packages/app/src/context/PlayerContext.tsx b/packages/app/src/context/PlayerContext.tsx new file mode 100644 index 00000000..41685367 --- /dev/null +++ b/packages/app/src/context/PlayerContext.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import type { HlsPlayer } from "@superstreamer/player"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; + +interface PlayerProviderProps { + children: ReactNode; +} + +const PlayerContext = createContext<{ + player: HlsPlayer | null; + setPlayer: Dispatch>; +} | null>(null); + +export function PlayerProvider({ children }: PlayerProviderProps) { + const [player, setPlayer] = useState(null); + + useEffect(() => { + Object.assign(window, { player }); + }, [player]); + + return ( + + {children} + + ); +} + +export function WithPlayer({ children }: { children: ReactNode }) { + const { player } = usePlayer(); + if (!player) { + return null; + } + return children; +} + +export function usePlayer() { + const context = useContext(PlayerContext); + if (!context) { + throw new Error("Missing provider"); + } + return context; +} + +export function usePlayerSelector(selector: (player: HlsPlayer) => R) { + const { player } = usePlayer(); + if (!player) { + throw new Error("Missing player"); + } + + const getSelector = () => { + const value = selector(player); + if (typeof value === "function") { + return value.bind(player); + } + return value; + }; + + const [value, setValue] = useState(getSelector); + + useEffect(() => { + if (!player) { + return; + } + + const onUpdate = () => { + setValue(getSelector); + }; + + player.on("*", onUpdate); + return () => { + player.off("*", onUpdate); + }; + }, [player]); + + return value; +} diff --git a/packages/app/src/hooks/useSeekbar.ts b/packages/app/src/hooks/useSeekbar.ts new file mode 100644 index 00000000..fe54269b --- /dev/null +++ b/packages/app/src/hooks/useSeekbar.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; + +export function useSeekbar({ + min, + max, + onSeeked, +}: { + min: number; + max: number; + onSeeked: (value: number) => void; +}) { + const ref = useRef(null); + const len = max - min; + + const [hover, setHover] = useState(false); + const [seeking, setSeeking] = useState(false); + const [x, setX] = useState(0); + + const active = hover || seeking; + const value = min + x * len; + + const setPercentage = (event: PointerEvent | React.PointerEvent) => { + if (!ref.current) { + return; + } + + const rect = ref.current.getBoundingClientRect(); + let x = (event.clientX - rect.left) / rect.width; + if (x < 0) { + x = 0; + } else if (x > 1) { + x = 1; + } + + setX(x); + }; + + useEffect(() => { + document.body.style.userSelect = seeking ? "none" : ""; + }, [seeking]); + + useEffect(() => { + if (!active) { + return; + } + + const onPointerMove = (event: PointerEvent) => { + setPercentage(event); + }; + + const onPointerUp = (event: PointerEvent) => { + flushSync(() => { + setPercentage(event); + setSeeking(false); + }); + onSeeked(value); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + }, [active, window, value]); + + const rootProps = { + onMouseEnter: () => setHover(true), + onMouseLeave: () => setHover(false), + onPointerDown: (event: React.PointerEvent) => { + setPercentage(event); + setSeeking(true); + }, + ref, + }; + + return { + rootProps, + hover, + seeking, + value, + active, + x, + }; +} diff --git a/packages/app/src/routes/(dashboard)/_layout/player.tsx b/packages/app/src/routes/(dashboard)/_layout/player.tsx index 7a173b25..63e6f2e8 100644 --- a/packages/app/src/routes/(dashboard)/_layout/player.tsx +++ b/packages/app/src/routes/(dashboard)/_layout/player.tsx @@ -1,9 +1,20 @@ -import { Card, Modal, ModalBody, ModalContent } from "@nextui-org/react"; +import { + Card, + Modal, + ModalBody, + ModalContent, + Tab, + Tabs, +} from "@nextui-org/react"; 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 { PlayerControls } from "../../../components/PlayerControls"; +import { PlayerStats } from "../../../components/PlayerStats"; +import { ScrollCard } from "../../../components/ScrollCard"; +import { PlayerProvider, WithPlayer } from "../../../context/PlayerContext"; import { useSwaggerSchema } from "../../../hooks/useSwaggerSchema"; import type { FormRef } from "../../../components/Form"; @@ -13,7 +24,7 @@ export const Route = createFileRoute("/(dashboard)/_layout/player")({ function RouteComponent() { const formRef = useRef(null); - const [url, setUrl] = useState(null); + const [url, setUrl] = useState(""); const [error, setError] = useState(null); const schema = useSwaggerSchema( @@ -21,53 +32,81 @@ function RouteComponent() { "/session", ); + const onSave = async (body: string) => { + setError(null); + + const response = await fetch( + `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/session`, + { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body, + }, + ); + + const data = await response.json(); + if (response.ok) { + formRef.current?.setValue("url", data.url); + setUrl(data.url); + } else { + setError(data); + } + }; + return (
-
- - -
{ - setUrl(values.url); - }} - /> - -
- + +
+
+
+ +
+
+ + + + + { + setUrl(values.url); + }} + /> + + + + + + + + + + + + + + +
+
+ { - setError(null); - - const response = await fetch( - `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/session`, - { - method: "post", - headers: { - "Content-Type": "application/json", - }, - body, - }, - ); - - const data = await response.json(); - if (response.ok) { - formRef.current?.setValue("url", data.url); - setUrl(data.url); - } else { - setError(data); - } - }} + onSave={onSave} /> ( - value: T, - message = "value is null", -): asserts value is NonNullable { - if (value === null || value === undefined) { - throw Error(message); - } -} diff --git a/packages/player/src/facade/asset.ts b/packages/player/src/facade/asset.ts deleted file mode 100644 index 6702ec9e..00000000 --- a/packages/player/src/facade/asset.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { StateObserver } from "./state-observer"; -import type { StateObserverEmit } from "./state-observer"; -import type Hls from "hls.js"; -import type { HlsAssetPlayer } from "hls.js"; - -export class Asset { - assetPlayer?: HlsAssetPlayer; - - hls: Hls; - - private observer_: StateObserver; - - constructor(item: Hls | HlsAssetPlayer, emit: StateObserverEmit) { - if ("hls" in item) { - this.hls = item.hls; - this.assetPlayer = item; - } else { - this.hls = item; - } - - this.observer_ = new StateObserver(this.hls, emit); - } - - destroy() { - this.observer_.destroy(); - } - - get state() { - return this.observer_.state; - } - - get observer() { - return this.observer_; - } - - get media() { - return this.hls.media; - } -} diff --git a/packages/player/src/facade/facade.ts b/packages/player/src/facade/facade.ts deleted file mode 100644 index 3722d4ed..00000000 --- a/packages/player/src/facade/facade.ts +++ /dev/null @@ -1,452 +0,0 @@ -import EventEmitter from "eventemitter3"; -import Hls from "hls.js"; -import { assert } from "./assert"; -import { Asset } from "./asset"; -import { getAssetListItem, getTypes, pipeState } from "./helpers"; -import { MediaManager } from "./media-manager"; -import { Events } from "./types"; -import type { StateObserverEmit } from "./state-observer"; -import type { - HlsFacadeListeners, - Interstitial, - InterstitialChangeEventData, - PlayheadChangeEventData, -} from "./types"; -import type { - HlsAssetPlayer, - InterstitialAssetEndedData, - InterstitialAssetPlayerCreatedData, - InterstitialAssetStartedData, - InterstitialsUpdatedData, -} from "hls.js"; - -export interface HlsFacadeOptions { - multipleVideoElements: boolean; -} - -/** - * A facade wrapper that simplifies working with HLS.js API. - */ -export class HlsFacade { - private options_: HlsFacadeOptions; - - private emitter_ = new EventEmitter(); - - private primaryAsset_: Asset | null = null; - - private interstitialAssets_ = new Map(); - - private state_: DominantState | null = null; - - private interstitial_: Interstitial | null = null; - - private mediaManager_: MediaManager | null = null; - - cuePoints: number[] = []; - - constructor( - public hls: Hls, - userOptions?: Partial, - ) { - this.options_ = { - // Add default values. - multipleVideoElements: false, - ...userOptions, - }; - - hls.on(Hls.Events.BUFFER_RESET, this.onBufferReset_, this); - hls.on(Hls.Events.MANIFEST_LOADED, this.onManifestLoaded_, this); - hls.on( - Hls.Events.INTERSTITIALS_UPDATED, - this.onInterstitialsUpdated_, - this, - ); - hls.on( - Hls.Events.INTERSTITIAL_ASSET_PLAYER_CREATED, - this.onInterstitialAssetPlayerCreated_, - this, - ); - hls.on( - Hls.Events.INTERSTITIAL_ASSET_STARTED, - this.onInterstitialAssetStarted_, - this, - ); - hls.on( - Hls.Events.INTERSTITIAL_ASSET_ENDED, - this.onInterstitialAssetEnded_, - this, - ); - hls.on( - Hls.Events.INTERSTITIALS_PRIMARY_RESUMED, - this.onInterstitialsPrimaryResumed_, - this, - ); - - if (hls.media) { - // We have media attached when we created the facade, fire it. - this.onMediaAttached_(); - } else { - // Wait once until we can grab the media. - hls.once(Hls.Events.MEDIA_ATTACHED, this.onMediaAttached_, this); - } - } - - on( - event: E, - listener: HlsFacadeListeners[E], - ) { - this.emitter_.on(event, listener); - } - - off( - event: E, - listener: HlsFacadeListeners[E], - ) { - this.emitter_.off(event, listener); - } - - /** - * Destroys the facade. - */ - destroy() { - this.hls.off(Hls.Events.BUFFER_RESET, this.onBufferReset_, this); - this.hls.off(Hls.Events.MANIFEST_LOADED, this.onManifestLoaded_, this); - this.hls.off( - Hls.Events.INTERSTITIALS_UPDATED, - this.onInterstitialsUpdated_, - this, - ); - this.hls.off( - Hls.Events.INTERSTITIAL_ASSET_PLAYER_CREATED, - this.onInterstitialAssetPlayerCreated_, - this, - ); - this.hls.off( - Hls.Events.INTERSTITIAL_ASSET_STARTED, - this.onInterstitialAssetStarted_, - this, - ); - this.hls.off( - Hls.Events.INTERSTITIAL_ASSET_ENDED, - this.onInterstitialAssetEnded_, - this, - ); - this.hls.off( - Hls.Events.INTERSTITIALS_PRIMARY_RESUMED, - this.onInterstitialsPrimaryResumed_, - this, - ); - this.hls.off(Hls.Events.MEDIA_ATTACHED, this.onMediaAttached_, this); - - this.disposeAssets_(); - } - - /** - * We're ready when the master playlist is loaded. - */ - get ready() { - return this.state_ !== null; - } - - /** - * We're started when atleast 1 asset started playback, either the master - * or interstitial playlist started playing. - */ - get started() { - return this.state_?.started ?? false; - } - - /** - * Returns the playhead, will preserve the user intent across interstitials. - * When we're switching to an interstitial, and the user explicitly requested play, - * we'll still return the state as playing. - */ - get playhead() { - const playhead = pipeState("playhead", this.activeAsset_); - if ( - (playhead === "pause" || playhead === "idle") && - this.state_?.playRequested - ) { - // We explicitly requested play, we didn't pause ourselves. Assume - // this is an interstitial transition. - return "playing"; - } - return playhead; - } - - /** - * Time of the primary asset. - */ - get time() { - return pipeState("time", this.primaryAsset_); - } - - /** - * Duration of the primary asset. - */ - get duration() { - if (this.hls.interstitialsManager) { - return this.hls.interstitialsManager.primary.duration; - } - return pipeState("duration", this.primaryAsset_); - } - - /** - * Whether auto quality is enabled for all assets. - */ - get autoQuality() { - return pipeState("autoQuality", this.primaryAsset_); - } - - /** - * Qualities list of the primary asset. - */ - get qualities() { - return pipeState("qualities", this.primaryAsset_); - } - - /** - * Audio tracks of the primary asset. - */ - get audioTracks() { - return pipeState("audioTracks", this.primaryAsset_); - } - - /** - * Subtitle tracks of the primary asset. - */ - get subtitleTracks() { - return pipeState("subtitleTracks", this.primaryAsset_); - } - - /** - * Volume across all assets. - */ - get volume() { - return pipeState("volume", this.activeAsset_); - } - - /** - * When currently playing an interstitial, this holds all the info - * from that interstitial, such as time / duration, ... - */ - get interstitial() { - return this.interstitial_; - } - - /** - * Toggles play or pause. - */ - playOrPause() { - if (!this.state_) { - return; - } - const media = this.activeAsset_?.media; - if (!media) { - return; - } - if (this.playhead === "play" || this.playhead === "playing") { - media.pause(); - this.state_.playRequested = false; - } else { - media.play(); - this.state_.playRequested = true; - } - } - - /** - * Seek to a time in primary content. - * @param targetTime - */ - seekTo(targetTime: number) { - if (this.hls.interstitialsManager) { - if (!targetTime) { - targetTime = 0.001; - } else if (targetTime > this.duration - 0.1) { - targetTime = this.duration - 0.1; - } - this.hls.interstitialsManager.primary.seekTo(targetTime); - } else if (this.primaryAsset_?.media) { - this.primaryAsset_.media.currentTime = targetTime; - } - } - - /** - * Sets volume. - * @param volume - */ - setVolume(volume: number) { - // We'll pass this on to the media manager, in case we have multiple - // media elements, we'll set volume for all. - this.mediaManager_?.setVolume(volume); - } - - /** - * Sets quality by id. All quality levels are defined in `qualities`. - * @param height - */ - setQuality(height: number | null) { - this.primaryAsset_?.observer.setQuality(height); - } - - /** - * Sets subtitle by id. All subtitle tracks are defined in `subtitleTracks`. - * @param id - */ - setSubtitleTrack(id: number | null) { - this.primaryAsset_?.observer.setSubtitleTrack(id); - } - - /** - * Sets audio by id. All audio tracks are defined in `audioTracks`. - * @param id - */ - setAudioTrack(id: number) { - this.primaryAsset_?.observer.setAudioTrack(id); - } - - private onBufferReset_() { - this.disposeAssets_(); - - this.state_ = null; - this.interstitial_ = null; - - // In case anyone is listening, reset your state. - this.emitter_.emit(Events.RESET); - - this.primaryAsset_ = new Asset(this.hls, this.observerEmit_); - } - - private onManifestLoaded_() { - this.state_ = {}; - - this.emitter_.emit(Events.READY); - } - - private onInterstitialsUpdated_(_: string, data: InterstitialsUpdatedData) { - this.cuePoints = data.schedule.reduce((acc, item) => { - const types = getTypes(item); - if (types?.ad && !acc.includes(item.start)) { - acc.push(item.start); - } - return acc; - }, []); - } - - private onInterstitialAssetPlayerCreated_( - _: string, - data: InterstitialAssetPlayerCreatedData, - ) { - this.mediaManager_?.attachMedia(data.player); - - const asset = new Asset(data.player, this.observerEmit_); - this.interstitialAssets_.set(data.player, asset); - } - - private onInterstitialAssetStarted_( - _: string, - data: InterstitialAssetStartedData, - ) { - const asset = this.interstitialAssets_.get(data.player); - assert(asset, "No asset for interstitials player"); - - this.mediaManager_?.setActive(data.player); - - const assetListItem = getAssetListItem(data); - - this.interstitial_ = { - get time() { - return pipeState("time", asset); - }, - get duration() { - return pipeState("duration", asset); - }, - player: data.player, - asset: data.asset, - type: assetListItem.type, - }; - - this.emitter_.emit(Events.INTERSTITIAL_CHANGE, { - interstitial: this.interstitial_, - } satisfies InterstitialChangeEventData); - } - - private onInterstitialAssetEnded_( - _: string, - data: InterstitialAssetEndedData, - ) { - this.interstitialAssets_.delete(data.player); - this.interstitial_ = null; - - this.emitter_.emit(Events.INTERSTITIAL_CHANGE, { - interstitial: null, - } satisfies InterstitialChangeEventData); - } - - private onMediaAttached_() { - assert(this.hls.media); - - this.mediaManager_ = new MediaManager( - this.hls.media, - this.options_.multipleVideoElements, - ); - } - - private onInterstitialsPrimaryResumed_() { - assert(this.mediaManager_); - this.mediaManager_.reset(); - } - - private disposeAssets_() { - this.primaryAsset_?.destroy(); - this.primaryAsset_ = null; - - this.interstitialAssets_.forEach((asset) => { - asset.destroy(); - }); - this.interstitialAssets_.clear(); - } - - private observerEmit_: StateObserverEmit = (hls, event, eventObj) => { - if (hls !== this.primaryAsset_?.hls && hls !== this.activeAsset_?.hls) { - // If it's not the primary asset, and it's not an interstitial that is currently - // active, we skip events from it. The interstitial is still preparing. - return; - } - - this.dominantStateSideEffect_(event, eventObj); - - this.emitter_.emit(event, eventObj); - this.emitter_.emit("*"); - }; - - private dominantStateSideEffect_( - event: E, - eventObj: Parameters[0], - ) { - if (!this.state_) { - return; - } - - // If we started atleast something, we've got a dominant started state. - if (!this.state_.started && event === Events.PLAYHEAD_CHANGE) { - const data = eventObj as PlayheadChangeEventData; - this.state_.started = data.started; - } - } - - private get activeAsset_() { - if (this.interstitial_) { - return this.interstitialAssets_.get(this.interstitial_.player) ?? null; - } - return this.primaryAsset_; - } -} - -/** - * Overarching state, across all assets. - */ -interface DominantState { - started?: boolean; - playRequested?: boolean; -} diff --git a/packages/player/src/facade/helpers.ts b/packages/player/src/facade/helpers.ts deleted file mode 100644 index d82ac4a8..00000000 --- a/packages/player/src/facade/helpers.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { langMap } from "./lang-map"; -import type { Asset } from "./asset"; -import type { CustomInterstitialType, State } from "./types"; -import type { - InterstitialAssetStartedData, - InterstitialScheduleItem, -} from "hls.js"; - -export function updateActive( - items: T[], - getActive: (item: T) => boolean, -) { - const current = items.findIndex((item) => item.active); - const active = items.findIndex((item) => getActive(item)); - - if (current === active) { - return items; - } - - const nextItems: T[] = []; - - for (const item of items) { - const nextActive = getActive(item); - if (item.active === nextActive) { - nextItems.push(item); - continue; - } - nextItems.push({ ...item, active: nextActive }); - } - - return nextItems; -} - -export function preciseFloat(value: number) { - return Math.round((value + Number.EPSILON) * 100) / 100; -} - -export function getLang(key?: string) { - const value = key ? langMap[key]?.split(",")[0] : null; - if (!value) { - return "Unknown"; - } - return `${value[0].toUpperCase()}${value.slice(1)}`; -} - -export function getAssetListItem(data: InterstitialAssetStartedData): { - type?: CustomInterstitialType; -} { - const assetListItem = data.event.assetListResponse?.ASSETS[ - data.assetListIndex - ] as - | { - "SPRS-TYPE"?: CustomInterstitialType; - } - | undefined; - - return { - type: assetListItem?.["SPRS-TYPE"], - }; -} - -export function getTypes(item: InterstitialScheduleItem) { - if (!item.event) { - return null; - } - return item.event.dateRange.attr.enumeratedStringList("X-SPRS-TYPES", { - ad: false, - bumper: false, - } satisfies Record); -} - -const noState: State = { - playhead: "idle", - started: false, - time: 0, - duration: NaN, - volume: 0, - autoQuality: false, - qualities: [], - audioTracks: [], - subtitleTracks: [], -}; - -export function pipeState

( - prop: P, - asset: Asset | null, -): State[P] { - if (!asset) { - return noState[prop]; - } - return asset.state[prop]; -} diff --git a/packages/player/src/facade/index.ts b/packages/player/src/facade/index.ts deleted file mode 100644 index 2f6a2d55..00000000 --- a/packages/player/src/facade/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./facade"; -export * from "./types"; diff --git a/packages/player/src/facade/media-manager.ts b/packages/player/src/facade/media-manager.ts deleted file mode 100644 index 0f126117..00000000 --- a/packages/player/src/facade/media-manager.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { HlsAssetPlayer } from "hls.js"; - -export class MediaManager { - /** - * All additional media elements besides the primary. - */ - private mediaElements_: HTMLMediaElement[] = []; - - private index_ = 0; - - constructor( - private media_: HTMLMediaElement, - private multiple_: boolean, - ) { - if (this.multiple_) { - this.createMediaElements_(); - } - } - - attachMedia(player: HlsAssetPlayer) { - if (!this.multiple_) { - // We do not want to use multiple video elements. - return; - } - - // Grab a media element from the pool and bump index for the next. - const media = this.mediaElements_[this.index_]; - this.index_ += 1; - this.index_ %= this.mediaElements_.length; - - player.attachMedia(media); - } - - setActive(player: HlsAssetPlayer) { - if ( - !player.media || - // This is not a mediaElement that we created. - !this.mediaElements_.includes(player.media) - ) { - return; - } - this.forwardMedia_(player.media); - } - - reset() { - // Set the primary element back to front. - this.forwardMedia_(this.media_); - } - - setVolume(volume: number) { - this.media_.volume = volume; - for (const media of this.mediaElements_) { - media.volume = volume; - } - } - - private createMediaElements_() { - if (this.media_.parentElement?.tagName !== "DIV") { - throw new Error("The parent of the media element is not a div."); - } - - const container = this.media_.parentElement as HTMLDivElement; - // Create 2 video elements so we can transition smoothly from one - // interstitial to the other. - this.mediaElements_.push(createVideoElement(), createVideoElement()); - container.prepend(...this.mediaElements_); - - this.forwardMedia_(this.media_); - } - - /** - * Bring a media element to foreground. - * @param target - */ - private forwardMedia_(target: HTMLMediaElement) { - let found = false; - for (const media of this.mediaElements_) { - if (target === media) { - media.style.zIndex = "0"; - found = true; - } else { - media.style.zIndex = "-1"; - } - } - - // If we found a sub media element, we hide primary. - this.media_.style.zIndex = found ? "-1" : "0"; - } -} - -function createVideoElement() { - const el = document.createElement("video"); - el.style.position = "absolute"; - el.style.inset = "0"; - el.style.width = "100%"; - el.style.height = "100%"; - return el; -} diff --git a/packages/player/src/facade/state-observer.ts b/packages/player/src/facade/state-observer.ts deleted file mode 100644 index 0987d643..00000000 --- a/packages/player/src/facade/state-observer.ts +++ /dev/null @@ -1,356 +0,0 @@ -import Hls from "hls.js"; -import { assert } from "./assert"; -import { EventManager } from "./event-manager"; -import { getLang, preciseFloat, updateActive } from "./helpers"; -import { Timer } from "./timer"; -import { Events } from "./types"; -import type { - AudioTrack, - HlsFacadeListeners, - Playhead, - Quality, - State, - SubtitleTrack, -} from "./types"; -import type { Level, MediaPlaylist } from "hls.js"; - -export type StateObserverEmit = ( - hls: Hls, - event: E, - eventObj: Parameters[0], -) => void; - -export class StateObserver { - private eventManager_ = new EventManager(); - - private mediaEventManager_: EventManager | null = null; - - state: State = { - playhead: "idle", - started: false, - time: 0, - duration: NaN, - volume: 0, - autoQuality: true, - qualities: [], - audioTracks: [], - subtitleTracks: [], - }; - - private timeTick_ = new Timer(() => this.onTimeTick_()); - - constructor( - public hls: Hls, - private emit_: StateObserverEmit, - ) { - const listen = this.eventManager_.listen(hls); - - listen(Hls.Events.MANIFEST_LOADED, this.onManifestLoaded_, this); - listen(Hls.Events.BUFFER_CREATED, this.onBufferCreated_, this); - listen(Hls.Events.LEVELS_UPDATED, this.onLevelsUpdated_, this); - listen(Hls.Events.LEVEL_SWITCHING, this.onLevelSwitching_, this); - listen(Hls.Events.MEDIA_ATTACHED, this.onMediaAttached_, this); - listen(Hls.Events.MEDIA_DETACHED, this.onMediaDetached_, this); - listen( - Hls.Events.SUBTITLE_TRACKS_UPDATED, - this.onSubtitleTracksUpdated_, - this, - ); - listen(Hls.Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch_, this); - listen(Hls.Events.AUDIO_TRACKS_UPDATED, this.onAudioTracksUpdated_, this); - listen(Hls.Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching_, this); - - if (hls.media) { - // Looks like we already have media attached, bind listeners immediately. - this.onMediaAttached_(); - } - } - - private onManifestLoaded_() { - this.onLevelsUpdated_(); - } - - private onBufferCreated_() { - this.timeTick_.tickNow(); - } - - private onAudioTracksUpdated_() { - const tracks = this.hls.allAudioTracks.map((track, index) => { - let label = getLang(track.lang); - if (track.channels === "6") { - label += " 5.1"; - } - return { - id: index, - active: this.isAudioTrackActive_(track), - label, - track, - }; - }); - - this.state.audioTracks = tracks; - - this.dispatchEvent_(Events.AUDIO_TRACKS_CHANGE, { audioTracks: tracks }); - } - - private isAudioTrackActive_(t: MediaPlaylist) { - if (!this.hls.audioTracks.includes(t)) { - return false; - } - return t.id === this.hls.audioTrack; - } - - private isSubtitleTrackActive_(t: MediaPlaylist) { - if (!this.hls.subtitleTracks.includes(t)) { - return false; - } - return t.id === this.hls.subtitleTrack; - } - - private onAudioTrackSwitching_() { - const newTracks = updateActive(this.state.audioTracks, (t) => - this.isAudioTrackActive_(t.track), - ); - - if (newTracks === this.state.audioTracks) { - return; - } - - this.state.audioTracks = newTracks; - - this.dispatchEvent_(Events.AUDIO_TRACKS_CHANGE, { - audioTracks: newTracks, - }); - } - - private onSubtitleTracksUpdated_() { - const tracks = this.hls.allSubtitleTracks.map( - (track, index) => ({ - id: index, - active: this.isSubtitleTrackActive_(track), - label: getLang(track.lang), - track, - }), - ); - - this.state.subtitleTracks = tracks; - - this.dispatchEvent_(Events.SUBTITLE_TRACKS_CHANGE, { - subtitleTracks: tracks, - }); - } - - private onSubtitleTrackSwitch_() { - const newTracks = updateActive(this.state.subtitleTracks, (t) => - this.isSubtitleTrackActive_(t.track), - ); - - if (newTracks === this.state.subtitleTracks) { - return; - } - - this.state.subtitleTracks = newTracks; - - this.dispatchEvent_(Events.SUBTITLE_TRACKS_CHANGE, { - subtitleTracks: newTracks, - }); - } - - setQuality(height: number | null) { - if (height === null) { - this.hls.nextLevel = -1; - } else { - const loadLevel = this.hls.levels[this.hls.loadLevel]; - assert(loadLevel, "No level found for loadLevel index"); - - const idx = this.hls.levels.findIndex((level) => { - return ( - level.height === height && - level.audioCodec?.substring(0, 4) === - loadLevel.audioCodec?.substring(0, 4) - ); - }); - - if (idx < 0) { - throw new Error("Could not find matching level"); - } - - this.hls.nextLevel = idx; - this.onLevelSwitching_(); - } - - const newAutoQuality = this.hls.autoLevelEnabled; - if (newAutoQuality !== this.state.autoQuality) { - this.state.autoQuality = newAutoQuality; - this.dispatchEvent_(Events.AUTO_QUALITY_CHANGE, { - autoQuality: newAutoQuality, - }); - } - } - - setSubtitleTrack(id: number | null) { - if (id === null) { - this.hls.subtitleTrack = -1; - return; - } - const subtitleTrack = this.hls.allSubtitleTracks[id]; - this.hls.setSubtitleOption({ - lang: subtitleTrack.lang, - name: subtitleTrack.name, - }); - } - - setAudioTrack(id: number) { - const audioTrack = this.hls.allAudioTracks[id]; - this.hls.setAudioOption({ - lang: audioTrack.lang, - channels: audioTrack.channels, - name: audioTrack.name, - }); - } - - destroy() { - this.eventManager_.removeAll(); - - this.mediaEventManager_?.removeAll(); - this.mediaEventManager_ = null; - - this.timeTick_.stop(); - } - - private onLevelsUpdated_() { - const map: Record = {}; - for (const level of this.hls.levels) { - if (!map[level.height]) { - map[level.height] = []; - } - map[level.height].push(level); - } - - const level = this.hls.levels[this.hls.nextLoadLevel]; - const mapEntries = Object.entries(map); - const qualities = mapEntries.reduce((acc, [key, levels]) => { - acc.push({ - height: +key, - active: +key === level?.height, - levels, - }); - return acc; - }, []); - - qualities.sort((a, b) => b.height - a.height); - - this.state.qualities = qualities; - this.dispatchEvent_(Events.QUALITIES_CHANGE, { qualities }); - } - - private onLevelSwitching_() { - const level = this.hls.levels[this.hls.nextLoadLevel]; - - const newQualities = updateActive( - this.state.qualities, - (q) => q.height === level.height, - ); - - if (newQualities === this.state.qualities) { - return; - } - - this.state.qualities = newQualities; - this.dispatchEvent_(Events.QUALITIES_CHANGE, { - qualities: this.state.qualities, - }); - } - - private onMediaAttached_() { - assert(this.hls.media); - - const media = this.hls.media; - const state = this.state; - - // Set initial state when we have media attached. - this.state.volume = media.volume; - - this.mediaEventManager_ = new EventManager(); - const listen = this.mediaEventManager_.listen(media); - - listen("play", () => this.setPlayhead_("play")); - - listen("playing", () => { - this.timeTick_.tickNow().tickEvery(0.25); - this.setPlayhead_("playing"); - }); - - listen("pause", () => { - this.timeTick_.tickNow(); - this.setPlayhead_("pause"); - }); - - listen("volumechange", () => { - state.volume = media.volume; - this.dispatchEvent_(Events.VOLUME_CHANGE, { volume: media.volume }); - }); - - listen("ended", () => this.setPlayhead_("ended")); - } - - private onMediaDetached_() { - this.timeTick_.stop(); - - this.mediaEventManager_?.removeAll(); - this.mediaEventManager_ = null; - } - - private onTimeTick_() { - let time = 0; - let duration = NaN; - - if (this.hls.media) { - time = this.hls.media.currentTime; - duration = this.hls.media.duration; - } - - const oldTime = this.state.time; - this.state.time = preciseFloat(time); - - const oldDuration = this.state.duration; - this.state.duration = preciseFloat(duration); - - if (isNaN(duration)) { - return; - } - - if (oldTime === this.state.time && oldDuration === this.state.duration) { - return; - } - - this.dispatchEvent_(Events.TIME_CHANGE, { - time: this.state.time, - duration: this.state.duration, - }); - } - - private setPlayhead_(playhead: Playhead) { - this.state.playhead = playhead; - - if (playhead === "playing") { - this.state.started = true; - } - - this.dispatchEvent_(Events.PLAYHEAD_CHANGE, { - playhead, - started: this.state.started, - }); - } - - private dispatchEvent_( - event: E, - eventObj: Parameters[0], - ) { - this.emit_(this.hls, event, eventObj); - } - - requestTimeTick() { - this.timeTick_.tickNow(); - } -} diff --git a/packages/player/src/facade/timer.ts b/packages/player/src/facade/timer.ts deleted file mode 100644 index 7f676946..00000000 --- a/packages/player/src/facade/timer.ts +++ /dev/null @@ -1,38 +0,0 @@ -export class Timer { - private timerId_?: number; - - constructor(private onTick_: () => void) {} - - tickNow() { - this.stop(); - - this.onTick_(); - - return this; - } - - tickAfter(seconds: number) { - this.stop(); - - this.timerId_ = window.setTimeout(() => { - this.onTick_(); - }, seconds * 1000); - - return this; - } - - tickEvery(seconds: number) { - this.stop(); - - this.timerId_ = window.setTimeout(() => { - this.onTick_(); - this.tickEvery(seconds); - }, seconds * 1000); - - return this; - } - - stop() { - clearTimeout(this.timerId_); - } -} diff --git a/packages/player/src/facade/types.ts b/packages/player/src/facade/types.ts deleted file mode 100644 index c816fc3d..00000000 --- a/packages/player/src/facade/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - HlsAssetPlayer, - InterstitialAssetItem, - Level, - MediaPlaylist, -} from "hls.js"; - -/** - * A custom type for each `ASSET`. - */ -export type CustomInterstitialType = "ad" | "bumper"; - -/** - * Defines an in-band subtitle track. - */ -export interface SubtitleTrack { - id: number; - active: boolean; - label: string; - track: MediaPlaylist; -} - -/** - * Defines an audio track. - */ -export interface AudioTrack { - id: number; - active: boolean; - label: string; - track: MediaPlaylist; -} - -/** - * Defines a quality level. - */ -export interface Quality { - height: number; - active: boolean; - levels: Level[]; -} - -/** - * State of playhead across all assets. - */ -export type Playhead = "idle" | "play" | "playing" | "pause" | "ended"; - -/** - * Defines an interstitial, which is not the primary content. - */ -export interface Interstitial { - time: number; - duration: number; - player: HlsAssetPlayer; - asset: InterstitialAssetItem; - type?: CustomInterstitialType; -} - -/** - * State variables. - */ -export interface State { - playhead: Playhead; - started: boolean; - time: number; - duration: number; - volume: number; - autoQuality: boolean; - qualities: Quality[]; - audioTracks: AudioTrack[]; - subtitleTracks: SubtitleTrack[]; -} - -/** - * List of events. - */ -export enum Events { - RESET = "reset", - READY = "ready", - PLAYHEAD_CHANGE = "playheadChange", - TIME_CHANGE = "timeChange", - VOLUME_CHANGE = "volumeChange", - QUALITIES_CHANGE = "qualitiesChange", - AUDIO_TRACKS_CHANGE = "audioTracksChange", - SUBTITLE_TRACKS_CHANGE = "subtitleTracksChange", - AUTO_QUALITY_CHANGE = "autoQualityChange", - INTERSTITIAL_CHANGE = "interstitialChange", -} - -export interface PlayheadChangeEventData { - playhead: Playhead; - started: boolean; -} - -export interface TimeChangeEventData { - time: number; - duration: number; -} - -export interface VolumeChangeEventData { - volume: number; -} - -export interface QualitiesChangeEventData { - qualities: Quality[]; -} - -export interface AudioTracksChangeEventData { - audioTracks: AudioTrack[]; -} - -export interface SubtitleTracksChangeEventData { - subtitleTracks: SubtitleTrack[]; -} - -export interface AutoQualityChangeEventData { - autoQuality: boolean; -} - -export interface InterstitialChangeEventData { - interstitial: Interstitial | null; -} - -/** - * List of events with their respective event handlers. - */ -export interface HlsFacadeListeners { - "*": () => void; - [Events.RESET]: () => void; - [Events.READY]: () => void; - [Events.PLAYHEAD_CHANGE]: (data: PlayheadChangeEventData) => void; - [Events.TIME_CHANGE]: (data: TimeChangeEventData) => void; - [Events.VOLUME_CHANGE]: (data: VolumeChangeEventData) => void; - [Events.QUALITIES_CHANGE]: (data: QualitiesChangeEventData) => void; - [Events.AUDIO_TRACKS_CHANGE]: (data: AudioTracksChangeEventData) => void; - [Events.SUBTITLE_TRACKS_CHANGE]: ( - data: SubtitleTracksChangeEventData, - ) => void; - [Events.AUTO_QUALITY_CHANGE]: (data: AutoQualityChangeEventData) => void; - [Events.INTERSTITIAL_CHANGE]: (data: InterstitialChangeEventData) => void; -} diff --git a/packages/player/src/facade/lang-map.ts b/packages/player/src/helpers.ts similarity index 91% rename from packages/player/src/facade/lang-map.ts rename to packages/player/src/helpers.ts index 396b709b..dcc3d1b3 100644 --- a/packages/player/src/facade/lang-map.ts +++ b/packages/player/src/helpers.ts @@ -1,6 +1,18 @@ +export function preciseFloat(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +export function getLangCode(key?: string) { + const value = key ? langCodes[key]?.split(",")[0] : null; + if (!value) { + return "Unknown"; + } + return `${value[0].toUpperCase()}${value.slice(1)}`; +} + // Inspired by iso-language-codes. // See https://github.com/pubcore/iso-language-codes/blob/master/src/data.ts -export const langMap: Record = { +const langCodes: Record = { sr: "српски језик", ro: "Română", ii: "ꆈꌠ꒿ Nuosuhxop", @@ -35,8 +47,8 @@ export const langMap: Record = { qu: "Runa Simi, Kichwa", sc: "sardu", sw: "Kiswahili", - uz: "Oʻzbek, Ўзбек, ", - za: "Saɯ cueŋƅ, Saw cuengh", + uz: "O'zbek, Ўзбек, ", + za: "Saw cueŋƅ, Saw cuengh", bi: "Bislama", nb: "Norsk Bokmål", nn: "Norsk Nynorsk", diff --git a/packages/player/src/hls-player.ts b/packages/player/src/hls-player.ts new file mode 100644 index 00000000..aab13f60 --- /dev/null +++ b/packages/player/src/hls-player.ts @@ -0,0 +1,420 @@ +import Hls from "hls.js"; +import { assert } from "shared/assert"; +import { EventEmitter } from "tseep"; +import { EventManager } from "./event-manager"; +import { getLangCode } from "./helpers"; +import { getState, State } from "./state"; +import type { + AudioTrack, + HlsPlayerEventMap, + Quality, + SubtitleTrack, +} from "./types"; +import type { Level } from "hls.js"; + +export class HlsPlayer { + private media_: HTMLMediaElement; + + private eventManager_ = new EventManager(); + + private hls_: Hls | null = null; + + private state_: State | null = null; + + private emitter_ = new EventEmitter(); + + constructor(public container: HTMLDivElement) { + this.media_ = this.createMedia_(); + + // Make sure we're in unload state. + this.unload(); + } + + 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) { + this.bindMediaListeners_(); + const hls = this.createHls_(); + + this.state_ = new State({ + emitter: this.emitter_, + getTiming: () => ({ + primary: hls.interstitialsManager?.primary ?? hls.media, + asset: hls.interstitialsManager?.playerQueue.find( + (player) => + player.assetItem === hls.interstitialsManager?.playingAsset, + ), + }), + }); + + hls.attachMedia(this.media_); + hls.loadSource(url); + + this.hls_ = hls; + } + + unload() { + this.eventManager_.removeAll(); + this.state_ = null; + + if (this.hls_) { + this.hls_.destroy(); + this.hls_ = null; + } + } + + destroy() { + this.emitter_.removeAllListeners(); + this.unload(); + } + + on = this.emitter_.on.bind(this.emitter_); + off = this.emitter_.off.bind(this.emitter_); + once = this.emitter_.once.bind(this.emitter_); + + playOrPause() { + if (!this.state_) { + return; + } + const shouldPause = + this.state_.playhead === "play" || this.state_.playhead === "playing"; + if (shouldPause) { + this.media_.pause(); + } else { + this.media_.play(); + } + } + + seekTo(time: number) { + assert(this.hls_); + + if (this.hls_.interstitialsManager) { + this.hls_.interstitialsManager.primary.seekTo(time); + } else { + this.media_.currentTime = time; + } + } + + setQuality(height: number | null) { + assert(this.hls_); + + if (height === null) { + this.hls_.nextLevel = -1; + } else { + const loadLevel = this.hls_.levels[this.hls_.loadLevel]; + assert(loadLevel, "No level found for loadLevel index"); + + const idx = this.hls_.levels.findIndex((level) => { + return ( + level.height === height && + level.audioCodec?.substring(0, 4) === + loadLevel.audioCodec?.substring(0, 4) + ); + }); + + if (idx < 0) { + throw new Error("Could not find matching level"); + } + + this.hls_.nextLevel = idx; + } + + this.updateQualities_(); + } + + setAudioTrack(id: number) { + assert(this.hls_); + + const audioTrack = this.state_?.audioTracks.find( + (track) => track.id === id, + ); + assert(audioTrack); + + this.hls_.setAudioOption({ + lang: audioTrack.track.lang, + channels: audioTrack.track.channels, + name: audioTrack.track.name, + }); + } + + setSubtitleTrack(id: number | null) { + assert(this.hls_); + + if (id === null) { + this.hls_.subtitleTrack = -1; + return; + } + + const subtitleTrack = this.state_?.subtitleTracks.find( + (track) => track.id === id, + ); + assert(subtitleTrack); + + this.hls_.setSubtitleOption({ + lang: subtitleTrack.track.lang, + name: subtitleTrack.track.name, + }); + } + + setVolume(volume: number) { + this.media_.volume = volume; + this.media_.muted = volume === 0; + this.state_?.setVolume(volume); + } + + get ready() { + return getState(this.state_, "ready"); + } + + get playhead() { + return getState(this.state_, "playhead"); + } + + get started() { + return getState(this.state_, "started"); + } + + get time() { + return getState(this.state_, "time"); + } + + get duration() { + return getState(this.state_, "duration"); + } + + get seeking() { + return getState(this.state_, "seeking"); + } + + get interstitial() { + return getState(this.state_, "interstitial"); + } + + get qualities() { + return getState(this.state_, "qualities"); + } + + get autoQuality() { + return getState(this.state_, "autoQuality"); + } + + get audioTracks() { + return getState(this.state_, "audioTracks"); + } + + get subtitleTracks() { + return getState(this.state_, "subtitleTracks"); + } + + get volume() { + return getState(this.state_, "volume"); + } + + get seekableStart() { + if (this.hls_) { + return this.hls_.interstitialsManager?.primary?.seekableStart ?? 0; + } + return NaN; + } + + get live() { + return this.hls_?.levels[this.hls_.currentLevel]?.details?.live ?? false; + } + + get cuePoints() { + return getState(this.state_, "cuePoints"); + } + + private createHls_() { + const hls = new Hls(); + + const listen = this.eventManager_.listen(hls); + + listen(Hls.Events.MANIFEST_LOADED, () => { + this.updateQualities_(); + this.updateAudioTracks_(); + this.updateSubtitleTracks_(); + }); + + listen(Hls.Events.INTERSTITIAL_STARTED, () => { + this.state_?.setInterstitial({ + asset: null, + }); + }); + + listen(Hls.Events.INTERSTITIAL_ASSET_STARTED, (_, data) => { + const listResponseAsset = data.event.assetListResponse?.ASSETS[ + data.assetListIndex + ] as { + "SPRS-KIND"?: "ad" | "bumper"; + }; + + this.state_?.setAsset({ + type: listResponseAsset["SPRS-KIND"], + }); + }); + + listen(Hls.Events.INTERSTITIAL_ASSET_ENDED, () => { + this.state_?.setAsset(null); + }); + + listen(Hls.Events.INTERSTITIAL_ENDED, () => { + this.state_?.setInterstitial(null); + }); + + listen(Hls.Events.LEVELS_UPDATED, () => { + this.updateQualities_(); + }); + + listen(Hls.Events.LEVEL_SWITCHING, () => { + this.updateQualities_(); + }); + + listen(Hls.Events.AUDIO_TRACKS_UPDATED, () => { + this.updateAudioTracks_(); + }); + + listen(Hls.Events.AUDIO_TRACK_SWITCHING, () => { + this.updateAudioTracks_(); + }); + + listen(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => { + this.updateSubtitleTracks_(); + }); + + listen(Hls.Events.SUBTITLE_TRACK_SWITCH, () => { + this.updateSubtitleTracks_(); + }); + + listen(Hls.Events.INTERSTITIALS_UPDATED, (_, data) => { + const cuePoints = data.schedule.reduce((acc, item) => { + if (item.event) { + acc.push(item.start); + } + return acc; + }, []); + this.state_?.setCuePoints(cuePoints); + }); + + return hls; + } + + private updateQualities_() { + assert(this.hls_); + + const group: { + height: number; + levels: Level[]; + }[] = []; + + for (const level of this.hls_.levels) { + let item = group.find((item) => item.height === level.height); + if (!item) { + item = { + height: level.height, + levels: [], + }; + group.push(item); + } + item.levels.push(level); + } + + const level = this.hls_.levels[this.hls_.nextLoadLevel]; + + const qualities = group.map((item) => { + return { + ...item, + active: item.height === level.height, + }; + }); + + qualities.sort((a, b) => b.height - a.height); + + const autoQuality = this.hls_.autoLevelEnabled; + this.state_?.setQualities(qualities, autoQuality); + } + + private updateAudioTracks_() { + assert(this.hls_); + + const tracks = this.hls_.allAudioTracks.map((track, index) => { + let label = getLangCode(track.lang); + if (track.channels === "6") { + label += " 5.1"; + } + return { + id: index, + active: this.hls_?.audioTracks.includes(track) + ? track.id === this.hls_.audioTrack + : false, + label, + track, + }; + }); + + this.state_?.setAudioTracks(tracks); + } + + private updateSubtitleTracks_() { + assert(this.hls_); + + const tracks = this.hls_.allSubtitleTracks.map( + (track, index) => { + return { + id: index, + active: this.hls_?.subtitleTracks.includes(track) + ? track.id === this.hls_.subtitleTrack + : false, + label: getLangCode(track.lang), + track, + }; + }, + ); + + this.state_?.setSubtitleTracks(tracks); + } + + private bindMediaListeners_() { + const listen = this.eventManager_.listen(this.media_); + + listen("canplay", () => { + this.state_?.setReady(); + }); + + listen("play", () => { + this.state_?.setPlayhead("play"); + }); + + listen("playing", () => { + this.state_?.setStarted(); + + this.state_?.setPlayhead("playing"); + }); + + listen("pause", () => { + this.state_?.setPlayhead("pause"); + }); + + listen("volumechange", () => { + this.state_?.setVolume(this.media_.volume); + }); + + listen("seeking", () => { + this.state_?.setSeeking(true); + }); + + listen("seeked", () => { + this.state_?.setSeeking(false); + }); + } +} diff --git a/packages/player/src/index.ts b/packages/player/src/index.ts new file mode 100644 index 00000000..d88bf8f4 --- /dev/null +++ b/packages/player/src/index.ts @@ -0,0 +1,10 @@ +export { HlsPlayer } from "./hls-player"; +export { Events } from "./types"; + +export type { + Playhead, + HlsPlayerEventMap, + Quality, + AudioTrack, + SubtitleTrack, +} from "./types"; diff --git a/packages/player/src/react/ControllerProvider.tsx b/packages/player/src/react/ControllerProvider.tsx deleted file mode 100644 index 6d17cb49..00000000 --- a/packages/player/src/react/ControllerProvider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext } from "react"; -import type { Controller } from "./hooks/useController"; -import type { ReactNode } from "react"; - -export const ControllerContext = createContext( - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - {} as Controller, -); - -interface ControllerProviderProps { - children: ReactNode; - controller: Controller; -} - -export function ControllerProvider({ - children, - controller, -}: ControllerProviderProps) { - return ( - - {children} - - ); -} diff --git a/packages/player/src/react/controls/Controls.tsx b/packages/player/src/react/controls/Controls.tsx deleted file mode 100644 index 75aa5762..00000000 --- a/packages/player/src/react/controls/Controls.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Playback } from "./components/Playback"; -import { Start } from "./components/Start"; -import { AppStoreProvider } from "./context/AppStoreProvider"; -import { ParamsProvider } from "./context/ParamsProvider"; -import type { Lang, Metadata } from "./types"; - -export interface ControlsProps { - metadata?: Metadata; - lang?: Lang; -} - -export function Controls({ metadata, lang }: ControlsProps) { - return ( - - - - - - - ); -} diff --git a/packages/player/src/react/controls/components/BottomControls.tsx b/packages/player/src/react/controls/components/BottomControls.tsx deleted file mode 100644 index 24d27081..00000000 --- a/packages/player/src/react/controls/components/BottomControls.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { FullscreenButton } from "./FullscreenButton"; -import { Label } from "./Label"; -import { PlayPauseButton } from "./PlayPauseButton"; -import { SqButton } from "./SqButton"; -import { VolumeButton } from "./VolumeButton"; -import { useFacade, useSelector } from "../.."; -import { useAppStore } from "../hooks/useAppStore"; -import { useFakeTime } from "../hooks/useFakeTime"; -import { useSeekTo } from "../hooks/useSeekTo"; -import { useShowTextAudio } from "../hooks/useShowTextAudio"; -import ForwardIcon from "../icons/forward.svg"; -import SettingsIcon from "../icons/settings.svg"; -import SubtitlesIcon from "../icons/subtitles.svg"; -import type { SetAppSettings } from "../hooks/useAppSettings"; -import type { MouseEventHandler } from "react"; - -interface BottomControlsProps { - nudgeVisible(): void; - setAppSettings: SetAppSettings; - toggleFullscreen: MouseEventHandler; -} - -export function BottomControls({ - nudgeVisible, - setAppSettings, - toggleFullscreen, -}: BottomControlsProps) { - const facade = useFacade(); - - const interstitial = useSelector((facade) => facade.interstitial); - const volume = useSelector((facade) => facade.volume); - - const settings = useAppStore((state) => state.settings); - - const seekTo = useSeekTo(); - const fakeTime = useFakeTime(); - const showTextAudio = useShowTextAudio(); - - return ( -

- - { - seekTo(fakeTime + 10); - }} - > - - - facade.setVolume(volume)} - /> -