From c5fde3c23d325faa97ade92b32dbede9afbb3e4b Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 18 Oct 2024 21:33:05 +0200 Subject: [PATCH] Enhanced UI controls (#66) * More component splits * feat: Added tooltip * Added center icon pop * bugfix: When interstitial is in idle state, show playing when play is requested * Added settings and properties to dashboard for player * Dark mode --- packages/api/package.json | 2 +- packages/api/src/index.ts | 33 +- packages/api/src/swagger.ts | 71 ++++ packages/dashboard/package.json | 7 +- packages/dashboard/src/App.tsx | 15 +- packages/dashboard/src/components/Editor.tsx | 62 ++- .../dashboard/src/components/JobState.tsx | 23 +- .../dashboard/src/components/JobsList.tsx | 2 +- .../src/components/JsonHighlight.tsx | 10 +- packages/dashboard/src/components/Player.tsx | 11 +- .../src/components/PlayerAccordion.tsx | 108 +++++ .../src/components/PlayerMetadataForm.tsx | 66 +++ .../src/components/PlayerNpmInstall.tsx | 38 ++ .../dashboard/src/components/PlayerView.tsx | 37 ++ .../dashboard/src/components/SelectObject.tsx | 11 +- packages/dashboard/src/components/Sidebar.tsx | 4 + .../src/components/hooks/use-toast.ts | 186 +++++++++ .../dashboard/src/components/ui/accordion.tsx | 56 +++ .../src/components/ui/dropdown-menu.tsx | 198 +++++++++ .../src/components/ui/mode-toggle.tsx | 36 ++ .../dashboard/src/components/ui/resizable.tsx | 43 ++ .../src/components/ui/theme-provider.tsx | 73 ++++ .../dashboard/src/components/ui/toast.tsx | 127 ++++++ .../dashboard/src/components/ui/toaster.tsx | 33 ++ packages/dashboard/src/lib/syntax-styles.ts | 11 + packages/dashboard/src/pages/ApiPage.tsx | 6 +- packages/dashboard/src/pages/JobsPage.tsx | 2 +- packages/dashboard/src/pages/PlayerPage.tsx | 47 +-- packages/dashboard/tailwind.config.js | 152 ++++--- packages/dashboard/vite.config.ts | 1 + packages/player/src/facade/asset.ts | 4 + packages/player/src/facade/event-manager.ts | 15 +- packages/player/src/facade/facade.ts | 389 +++++++++++------- packages/player/src/facade/helpers.ts | 20 +- packages/player/src/facade/media-manager.ts | 98 +++++ packages/player/src/facade/state-observer.ts | 29 +- packages/player/src/facade/types.ts | 17 +- .../player/src/react/controls/Controls.tsx | 14 +- .../controls/components/BottomControls.tsx | 36 +- .../src/react/controls/components/Center.tsx | 15 +- .../controls/components/CenterIconPop.tsx | 53 +++ .../controls/components/FullscreenButton.tsx | 27 ++ .../components/InterstitialAdProgress.tsx | 22 + .../components/InterstitialProgress.tsx | 15 + .../src/react/controls/components/Label.tsx | 9 +- .../controls/components/PlayPauseButton.tsx | 32 ++ .../react/controls/components/Playback.tsx | 21 +- .../react/controls/components/Progress.tsx | 2 +- .../controls/components/QualitiesPane.tsx | 7 +- .../src/react/controls/components/Seekbar.tsx | 6 +- .../react/controls/components/Settings.tsx | 2 +- .../controls/components/SlotProgress.tsx | 27 -- .../react/controls/components/SqButton.tsx | 12 +- .../controls/components/SqButtonTooltip.tsx | 31 ++ .../src/react/controls/components/Start.tsx | 8 +- .../controls/components/TextAudioPane.tsx | 8 +- .../{ => context}/AppStoreProvider.tsx | 17 +- .../react/controls/context/ParamsProvider.tsx | 26 ++ .../react/controls/hooks/useAppFullscreen.ts | 2 +- .../react/controls/hooks/useAppSettings.ts | 2 +- .../src/react/controls/hooks/useAppStore.ts | 9 + .../src/react/controls/hooks/useAppVisible.ts | 6 +- .../src/react/controls/hooks/useFakeTime.ts | 2 +- .../src/react/controls/hooks/useI18n.ts | 15 + .../src/react/controls/hooks/useParams.ts | 6 + .../src/react/controls/hooks/useSeekTo.ts | 2 +- .../controls/hooks/useVisibleControls.ts | 2 +- packages/player/src/react/controls/i18n.ts | 26 ++ packages/player/src/react/controls/index.tsx | 1 + packages/player/src/react/controls/types.ts | 4 + pnpm-lock.yaml | 282 ++++++++++++- 71 files changed, 2347 insertions(+), 445 deletions(-) create mode 100644 packages/api/src/swagger.ts create mode 100644 packages/dashboard/src/components/PlayerAccordion.tsx create mode 100644 packages/dashboard/src/components/PlayerMetadataForm.tsx create mode 100644 packages/dashboard/src/components/PlayerNpmInstall.tsx create mode 100644 packages/dashboard/src/components/PlayerView.tsx create mode 100644 packages/dashboard/src/components/hooks/use-toast.ts create mode 100644 packages/dashboard/src/components/ui/accordion.tsx create mode 100644 packages/dashboard/src/components/ui/dropdown-menu.tsx create mode 100644 packages/dashboard/src/components/ui/mode-toggle.tsx create mode 100644 packages/dashboard/src/components/ui/resizable.tsx create mode 100644 packages/dashboard/src/components/ui/theme-provider.tsx create mode 100644 packages/dashboard/src/components/ui/toast.tsx create mode 100644 packages/dashboard/src/components/ui/toaster.tsx create mode 100644 packages/dashboard/src/lib/syntax-styles.ts create mode 100644 packages/player/src/facade/media-manager.ts create mode 100644 packages/player/src/react/controls/components/CenterIconPop.tsx create mode 100644 packages/player/src/react/controls/components/FullscreenButton.tsx create mode 100644 packages/player/src/react/controls/components/InterstitialAdProgress.tsx create mode 100644 packages/player/src/react/controls/components/InterstitialProgress.tsx create mode 100644 packages/player/src/react/controls/components/PlayPauseButton.tsx delete mode 100644 packages/player/src/react/controls/components/SlotProgress.tsx create mode 100644 packages/player/src/react/controls/components/SqButtonTooltip.tsx rename packages/player/src/react/controls/{ => context}/AppStoreProvider.tsx (86%) create mode 100644 packages/player/src/react/controls/context/ParamsProvider.tsx create mode 100644 packages/player/src/react/controls/hooks/useAppStore.ts create mode 100644 packages/player/src/react/controls/hooks/useI18n.ts create mode 100644 packages/player/src/react/controls/hooks/useParams.ts create mode 100644 packages/player/src/react/controls/i18n.ts diff --git a/packages/api/package.json b/packages/api/package.json index f71ccb13..edb207c0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -6,7 +6,7 @@ "./client": "./src/client.ts" }, "scripts": { - "dev": "bun run ./src/index.ts --watch", + "dev": "bun --watch ./src/index.ts", "build": "bun build ./src/index.ts --target=bun --outdir=./dist", "lint": "eslint \"./src/**/*.ts\" && prettier --check \"./src/**/*.ts\"", "typecheck": "tsc" diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 306725c2..6ee8d11e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,6 @@ import { Elysia, t } from "elysia"; import { cors } from "@elysiajs/cors"; -import { swagger } from "@elysiajs/swagger"; +import { swagger, onAfterHandle } from "./swagger"; import { addTranscodeJob, addPackageJob } from "@mixwave/artisan/producer"; import { LangCodeSchema, @@ -14,36 +14,9 @@ import { StorageFolderSchema, StorageFileSchema, JobSchema } from "./types"; export type App = typeof app; -const CUSTOM_SCALAR_CSS = ` - .scalar-container.z-overlay { - padding-left: 16px; - padding-right: 16px; - } - - .scalar-api-client__send-request-button, .show-api-client-button { - background: var(--scalar-button-1); - } -`; - const app = new Elysia() .use(cors()) - .use( - swagger({ - documentation: { - info: { - title: "Mixwave API", - description: - "The Mixwave API is organized around REST, returns JSON-encoded responses " + - "and uses standard HTTP response codes and verbs.", - version: "1.0.0", - }, - }, - scalarConfig: { - hideDownloadButton: true, - customCss: CUSTOM_SCALAR_CSS, - }, - }), - ) + .use(swagger) .model({ LangCode: LangCodeSchema, VideoCodec: VideoCodecSchema, @@ -287,6 +260,8 @@ const app = new Elysia() }, ); +app.onAfterHandle(onAfterHandle); + app.on("stop", () => { process.exit(0); }); diff --git a/packages/api/src/swagger.ts b/packages/api/src/swagger.ts new file mode 100644 index 00000000..505fc303 --- /dev/null +++ b/packages/api/src/swagger.ts @@ -0,0 +1,71 @@ +import { swagger as elysiaSwagger } from "@elysiajs/swagger"; + +const CUSTOM_SCALAR_CSS = ` + .scalar-container.z-overlay { + padding-left: 16px; + padding-right: 16px; + } + + .scalar-api-client__send-request-button, .show-api-client-button { + background: var(--scalar-button-1); + } +`; + +export const swagger = elysiaSwagger({ + documentation: { + info: { + title: "Mixwave API", + description: + "The Mixwave API is organized around REST, returns JSON-encoded responses " + + "and uses standard HTTP response codes and verbs.", + version: "1.0.0", + }, + }, + scalarConfig: { + hideDownloadButton: true, + customCss: CUSTOM_SCALAR_CSS, + }, +}); + +const scalarScript = ` + +`; + +export async function onAfterHandle({ + request, + response, +}: { + request: Request; + response: Response; +}) { + const url = new URL(request.url); + + if (url.pathname.endsWith("/swagger")) { + const text = await response.text(); + const lines = text.split("\n"); + lines.splice( + lines.findIndex((line) => line.trim() === ""), + 0, + scalarScript, + ); + return new Response(lines.join(""), { + headers: { + "content-type": "text/html; charset=utf8", + }, + }); + } + + return response; +} diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index c7dd0dda..ee3012a2 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.0", @@ -23,11 +24,13 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-query": "^5.51.21", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "copy-to-clipboard": "^3.3.3", "hls.js": "1.6.0-beta.1", "lucide-react": "^0.424.0", "monaco-editor": "^0.51.0", @@ -36,12 +39,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.2", + "react-resizable-panels": "^2.1.4", "react-router-dom": "^6.26.0", "react-syntax-highlighter": "^15.5.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "timeago.js": "4.0.0-beta.3", - "uniqolor": "^1.1.1" + "uniqolor": "^1.1.1", + "zod": "^3.23.8" }, "devDependencies": { "@mixwave/api": "workspace:*", diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index 854491c0..3d25029a 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -9,8 +9,10 @@ import { JobPage } from "@/pages/JobPage"; import { ApiPage } from "@/pages/ApiPage"; import { RootLayout } from "@/pages/RootLayout"; import { Suspense } from "react"; +import { Toaster } from "@/components/ui/toaster"; import { PlayerPage } from "./pages/PlayerPage"; import { StoragePage } from "./pages/StoragePage"; +import { ThemeProvider } from "@/components/ui/theme-provider"; const queryClient = new QueryClient(); @@ -49,10 +51,13 @@ const router = createBrowserRouter([ export function App() { return ( - - - - - + + + + + + + + ); } diff --git a/packages/dashboard/src/components/Editor.tsx b/packages/dashboard/src/components/Editor.tsx index 136924d2..44ab1a47 100644 --- a/packages/dashboard/src/components/Editor.tsx +++ b/packages/dashboard/src/components/Editor.tsx @@ -1,6 +1,7 @@ import MonacoEditor from "@monaco-editor/react"; -import type { BeforeMount, OnChange, OnMount } from "@monaco-editor/react"; import { useEffect, useState } from "react"; +import { useTheme } from "@/components/ui/theme-provider"; +import type { BeforeMount, OnChange, OnMount } from "@monaco-editor/react"; type EditorProps = { schema: object; @@ -15,6 +16,9 @@ export function Editor({ onSave, localStorageKey, }: EditorProps) { + const { theme } = useTheme(); + const style = useMonacoStyle(); + const [defaultValue] = useState(() => { const localStorageValue = localStorageKey ? localStorage.getItem(localStorageKey) @@ -53,23 +57,47 @@ export function Editor({ }; return ( -
-
-
{title}
+
+
+
{title}
+
+
+ {style} +
-
); } + +function useMonacoStyle() { + const { theme } = useTheme(); + + if (theme === "dark") { + return ( + + ); + } + + return null; +} diff --git a/packages/dashboard/src/components/JobState.tsx b/packages/dashboard/src/components/JobState.tsx index 2e82b1fa..df6cb065 100644 --- a/packages/dashboard/src/components/JobState.tsx +++ b/packages/dashboard/src/components/JobState.tsx @@ -8,18 +8,31 @@ import type { Job } from "@/api"; export function JobState({ state }: { state: Job["state"] }) { if (state === "completed") { - return createCircle("bg-emerald-200 text-emerald-800", Check); + return createCircle( + "bg-emerald-200 text-emerald-800 dark:bg-emerald-400", + Check, + ); } if (state === "failed") { - return createCircle("bg-red-200 text-red-800", X); + return createCircle("bg-red-200 text-red-800 dark:bg-red-400", X); } if (state === "running") { - return createCircle("bg-blue-200 text-blue-800", Loader, "animate-spin"); + return createCircle( + "bg-blue-200 text-blue-800 dark:bg-blue-400", + Loader, + "animate-spin", + ); } if (state === "skipped") { - return createCircle("bg-gray-200 text-gray-800", CircleOff); + return createCircle( + "bg-gray-200 text-gray-800 dark:bg-gray-400", + CircleOff, + ); } - return createCircle("bg-violet-200 text-violet-800", CircleDotDashed); + return createCircle( + "bg-violet-200 text-violet-800 dark:bg-gray-400", + CircleDotDashed, + ); } function createCircle( diff --git a/packages/dashboard/src/components/JobsList.tsx b/packages/dashboard/src/components/JobsList.tsx index bb78927d..52fee5b8 100644 --- a/packages/dashboard/src/components/JobsList.tsx +++ b/packages/dashboard/src/components/JobsList.tsx @@ -15,7 +15,7 @@ export function JobsList({ jobs }: JobsListProps) {
  • diff --git a/packages/dashboard/src/components/JsonHighlight.tsx b/packages/dashboard/src/components/JsonHighlight.tsx index 20468198..544efe8c 100644 --- a/packages/dashboard/src/components/JsonHighlight.tsx +++ b/packages/dashboard/src/components/JsonHighlight.tsx @@ -1,10 +1,8 @@ import { useMemo } from "react"; import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; import json from "react-syntax-highlighter/dist/esm/languages/hljs/json"; -import style from "react-syntax-highlighter/dist/esm/styles/hljs/stackoverflow-light"; - -style["hljs"].padding = "1rem"; -delete style["hljs"].background; +import { useTheme } from "@/components/ui/theme-provider"; +import { styleLight, styleDark } from "@/lib/syntax-styles"; SyntaxHighlighter.registerLanguage("json", json); @@ -13,6 +11,8 @@ type SyntaxHighlightProps = { }; export function JsonHighlight({ json }: SyntaxHighlightProps) { + const { theme } = useTheme(); + const data = useMemo(() => { const parsed = JSON.parse(json); return JSON.stringify(parsed, null, 2); @@ -21,7 +21,7 @@ export function JsonHighlight({ json }: SyntaxHighlightProps) { return ( {data} diff --git a/packages/dashboard/src/components/Player.tsx b/packages/dashboard/src/components/Player.tsx index dfc50615..8249d637 100644 --- a/packages/dashboard/src/components/Player.tsx +++ b/packages/dashboard/src/components/Player.tsx @@ -5,12 +5,15 @@ import { ControllerProvider, } from "@mixwave/player/react"; import { useEffect, useState } from "react"; +import type { Lang, Metadata } from "@mixwave/player/react"; type PlayerProps = { - url?: string; + url?: string | null; + metadata: Metadata; + lang: Lang; }; -export function Player({ url }: PlayerProps) { +export function Player({ url, lang, metadata }: PlayerProps) { const [hls] = useState(() => new Hls()); const controller = useController(hls); @@ -29,14 +32,14 @@ export function Player({ url }: PlayerProps) { return (
    ); diff --git a/packages/dashboard/src/components/PlayerAccordion.tsx b/packages/dashboard/src/components/PlayerAccordion.tsx new file mode 100644 index 00000000..3f6c0f78 --- /dev/null +++ b/packages/dashboard/src/components/PlayerAccordion.tsx @@ -0,0 +1,108 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/hooks/use-toast"; +import { PlayerMetadataForm } from "./PlayerMetadataForm"; +import copy from "copy-to-clipboard"; +import { SelectObject } from "./SelectObject"; +import type { SelectObjectItem } from "./SelectObject"; +import type { Metadata } from "@mixwave/player/react"; +import type { Lang } from "@mixwave/player/react"; + +type PlayerAccordionProps = { + masterUrl?: string; + metadata: Metadata; + setMetadata(metadata: Metadata): void; + lang: Lang; + setLang(lang: Lang): void; +}; + +export function PlayerAccordion({ + masterUrl, + metadata, + setMetadata, + lang, + setLang, +}: PlayerAccordionProps) { + const { toast } = useToast(); + + const languages: SelectObjectItem[] = [ + { value: "nld", label: "Nederlands" }, + { value: "eng", label: "English" }, + ]; + + return ( + + + Session + +
    + +
    + { + (event.target as HTMLInputElement).select(); + }} + readOnly + onChange={() => {}} + /> + +
    +
    +
    +
    + + Settings + +
    + + setLang(lang as Lang)} + /> +

    + ISO 6301 - 3 characters +

    +
    +

    + Influences the{" "} + + metadata + {" "} + object to the{" "} + + Controls + {" "} + component. +

    +
    + +
    +
    +
    +
    + ); +} diff --git a/packages/dashboard/src/components/PlayerMetadataForm.tsx b/packages/dashboard/src/components/PlayerMetadataForm.tsx new file mode 100644 index 00000000..25c9f66f --- /dev/null +++ b/packages/dashboard/src/components/PlayerMetadataForm.tsx @@ -0,0 +1,66 @@ +import { useForm } from "react-hook-form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import type { Metadata } from "@mixwave/player/react"; + +type PlayerMetadataFormProps = { + values: Metadata; + onSubmit(metadata: Metadata): void; +}; +export function PlayerMetadataForm({ + values, + onSubmit, +}: PlayerMetadataFormProps) { + const form = useForm({ + values, + }); + + const onChange = form.handleSubmit((values: Metadata) => { + onSubmit(values); + }); + + return ( +
    + + ( + + Title + + + + + Displays a title in the controls. + + + + )} + /> + ( + + Subtitle + + + + Adds a subtitle to the title. + + + )} + /> + + + ); +} diff --git a/packages/dashboard/src/components/PlayerNpmInstall.tsx b/packages/dashboard/src/components/PlayerNpmInstall.tsx new file mode 100644 index 00000000..d407cf13 --- /dev/null +++ b/packages/dashboard/src/components/PlayerNpmInstall.tsx @@ -0,0 +1,38 @@ +import LinkIcon from "lucide-react/icons/link"; + +export function PlayerNpmInstall() { + return ( +
    + + + + + + + + + + + + npm i @mixwave/player + + + + +
    + ); +} diff --git a/packages/dashboard/src/components/PlayerView.tsx b/packages/dashboard/src/components/PlayerView.tsx new file mode 100644 index 00000000..9d4b9e9b --- /dev/null +++ b/packages/dashboard/src/components/PlayerView.tsx @@ -0,0 +1,37 @@ +import { Player } from "@/components/Player"; +import { PlayerAccordion } from "@/components/PlayerAccordion"; +import { useState } from "react"; +import { PlayerNpmInstall } from "./PlayerNpmInstall"; +import type { Lang, Metadata } from "@mixwave/player/react"; + +type PlayerViewProps = { + masterUrl?: string; +}; + +export function PlayerView({ masterUrl }: PlayerViewProps) { + const [metadata, setMetadata] = useState({ + title: "", + subtitle: "", + }); + const [lang, setLang] = useState("eng"); + + return ( +
    +
    + +
    +
    + +
    +
    + +
    +
    + ); +} diff --git a/packages/dashboard/src/components/SelectObject.tsx b/packages/dashboard/src/components/SelectObject.tsx index 6d66e0db..2efc2afb 100644 --- a/packages/dashboard/src/components/SelectObject.tsx +++ b/packages/dashboard/src/components/SelectObject.tsx @@ -5,6 +5,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; export type SelectObjectItem = { label: React.ReactNode; @@ -15,15 +16,21 @@ type SelectObjectProps = { items: SelectObjectItem[]; value?: string; onChange(value?: string): void; + className?: string; }; -export function SelectObject({ items, value, onChange }: SelectObjectProps) { +export function SelectObject({ + items, + value, + onChange, + className, +}: SelectObjectProps) { return ( { - (event.target as HTMLInputElement).select(); - }} - onChange={() => {}} - /> -
    - - ) : null} + + + {error ? ( - -
    {JSON.stringify(error, null, 2)}
    -
    +
    + +
    {JSON.stringify(error, null, 2)}
    +
    +
    ) : null} -
    -
  • + + + ); } diff --git a/packages/dashboard/tailwind.config.js b/packages/dashboard/tailwind.config.js index 135f860b..2bba1a47 100644 --- a/packages/dashboard/tailwind.config.js +++ b/packages/dashboard/tailwind.config.js @@ -12,69 +12,95 @@ export default { ], prefix: "", theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, + container: { + center: 'true', + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + }, + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } }, plugins: [animate], }; diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 77ae3027..29bef0a0 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ clearScreen: false, server: { port: 52000, + hmr: false, }, build: { rollupOptions: { diff --git a/packages/player/src/facade/asset.ts b/packages/player/src/facade/asset.ts index e56557a8..059d6639 100644 --- a/packages/player/src/facade/asset.ts +++ b/packages/player/src/facade/asset.ts @@ -32,4 +32,8 @@ export class Asset { get observer() { return this.observer_; } + + get media() { + return this.hls.media; + } } diff --git a/packages/player/src/facade/event-manager.ts b/packages/player/src/facade/event-manager.ts index 6814d64d..0156a596 100644 --- a/packages/player/src/facade/event-manager.ts +++ b/packages/player/src/facade/event-manager.ts @@ -1,6 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Handler = (...args: any) => any; - type Target = { addEventListener?: Handler; removeEventListener?: Handler; @@ -59,6 +56,15 @@ export class EventManager { } } +/** + * Create a binding for a specific target. + * @param target + * @param type + * @param listener + * @param context + * @param once + * @returns + */ function createBinding( target: Target, type: string, @@ -100,3 +106,6 @@ function createBinding( } type Binding = ReturnType; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Handler = (...args: any) => any; diff --git a/packages/player/src/facade/facade.ts b/packages/player/src/facade/facade.ts index 55864bec..018e36cf 100644 --- a/packages/player/src/facade/facade.ts +++ b/packages/player/src/facade/facade.ts @@ -1,33 +1,57 @@ import Hls from "hls.js"; +import EventEmitter from "eventemitter3"; +import { getAssetListItem, getTypes, pipeState } from "./helpers"; +import { Asset } from "./asset"; +import { Events } from "./types"; +import { assert } from "./assert"; +import { MediaManager } from "./media-manager"; import type { InterstitialAssetEndedData, InterstitialAssetPlayerCreatedData, InterstitialAssetStartedData, + HlsAssetPlayer, } from "hls.js"; -import EventEmitter from "eventemitter3"; -import { getAssetListItem, getTypes, pipeState } from "./helpers"; -import { Asset } from "./asset"; -import { Events } from "./types"; import type { StateObserverEmit } from "./state-observer"; -import type { FacadeListeners, Interstitial } from "./types"; -import type { HlsAssetPlayer } from "hls.js"; +import type { + FacadeListeners, + Interstitial, + PlayheadChangeEventData, +} from "./types"; -type GenericState = { - started: boolean; - playRequested: boolean; +export type FacadeOptions = { + multipleVideoElements: boolean; }; +/** + * A facade wrapper that simplifies working with HLS.js API. + */ export class Facade { + private options_: FacadeOptions; + private emitter_ = new EventEmitter(); - private assets_ = new Set(); + private primaryAsset_: Asset | null = null; + + private interstitialAssets_ = new Map(); + + private state_: DominantState | null = null; - private genericState_: GenericState | null = null; + private interstitial_: Interstitial | null = null; - interstitial: Interstitial | null = null; + private mediaManager_: MediaManager | null = null; + + constructor( + public hls: Hls, + userOptions?: Partial, + ) { + this.options_ = { + // Add default values. + multipleVideoElements: false, + ...userOptions, + }; - constructor(public hls: Hls) { hls.on(Hls.Events.BUFFER_RESET, this.onBufferReset_, this); + hls.on(Hls.Events.MANIFEST_LOADED, this.onManifestLoaded_, this); hls.on( Hls.Events.INTERSTITIAL_ASSET_PLAYER_CREATED, this.onInterstitialAssetPlayerCreated_, @@ -43,6 +67,19 @@ export class Facade { 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: FacadeListeners[E]) { @@ -53,8 +90,12 @@ export class Facade { 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.INTERSTITIAL_ASSET_PLAYER_CREATED, this.onInterstitialAssetPlayerCreated_, @@ -70,101 +111,42 @@ export class Facade { 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_(); } - private onBufferReset_() { - this.disposeAssets_(); - - // In case anyone is listening, reset your state. - this.emitter_.emit(Events.RESET); - - // Build a generic map, eg; when we started atleast 1 asset, - // it means we started the session as a whole. - this.genericState_ = { - started: false, - playRequested: false, - }; - - const primaryAsset = new Asset(this.hls, this.observerEmit_); - this.assets_.add(primaryAsset); - } - - private onInterstitialAssetPlayerCreated_( - _: string, - data: InterstitialAssetPlayerCreatedData, - ) { - const interstitialAsset = new Asset(data.player, this.observerEmit_); - this.assets_.add(interstitialAsset); - } - - private onInterstitialAssetStarted_( - _: string, - data: InterstitialAssetStartedData, - ) { - const asset = this.getAssetByPlayer(data.player); - const assetListItem = getAssetListItem(data); - - this.interstitial = { - get time() { - return pipeState("time", asset); - }, - get duration() { - return pipeState("duration", asset); - }, - player: data.player, - type: assetListItem.type, - }; - } - - private onInterstitialAssetEnded_( - _: string, - data: InterstitialAssetEndedData, - ) { - const asset = this.getAssetByPlayer(data.player); - if (!asset) { - throw new Error( - "No asset for interstitials player. This is a bug, report", - ); - } - this.assets_.delete(asset); - this.interstitial = null; - } - - private disposeAssets_() { - this.assets_.forEach((asset) => { - asset.destroy(); - }); - this.assets_.clear(); - - this.interstitial = null; + /** + * We're ready when the master playlist is loaded. + */ + get ready() { + return this.state_ !== null; } - private observerEmit_: StateObserverEmit = (hls, event, eventObj) => { - if (hls !== this.primaryAsset?.hls && hls !== this.activeAsset?.hls) { - return; - } - - if ( - this.genericState_ && - event === Events.PLAYHEAD_CHANGE && - this.activeAsset?.state.started - ) { - this.genericState_.started = true; - } - - this.emitter_.emit(event, eventObj); - this.emitter_.emit("*"); - }; - + /** + * We're started when atleast 1 asset started playback, either the master + * or interstitial playlist started playing. + */ get started() { - return this.genericState_?.started ?? false; + 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" && this.genericState_?.playRequested) { + 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"; @@ -172,37 +154,61 @@ export class Facade { return playhead; } + /** + * Time of the primary asset. + */ get time() { - return pipeState("time", this.primaryAsset); + 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); + return pipeState("duration", this.primaryAsset_); } + /** + * Whether auto quality is enabled for all assets. + */ get autoQuality() { - return pipeState("autoQuality", this.primaryAsset); + return pipeState("autoQuality", this.primaryAsset_); } + /** + * Qualities list of the primary asset. + */ get qualities() { - return pipeState("qualities", this.primaryAsset); + return pipeState("qualities", this.primaryAsset_); } + /** + * Audio tracks of the primary asset. + */ get audioTracks() { - return pipeState("audioTracks", this.primaryAsset); + return pipeState("audioTracks", this.primaryAsset_); } + /** + * Subtitle tracks of the primary asset. + */ get subtitleTracks() { - return pipeState("subtitleTracks", this.primaryAsset); + return pipeState("subtitleTracks", this.primaryAsset_); } + /** + * Volume across all assets. + */ get volume() { - return pipeState("volume", this.activeAsset); + return pipeState("volume", this.activeAsset_); } + /** + * A list of ad cue points, can be used to plot on a seekbar. + */ get cuePoints() { const manager = this.hls.interstitialsManager; if (!manager) { @@ -217,23 +223,31 @@ export class Facade { }, []); } + /** + * 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.genericState_) { + if (!this.state_) { return; } - const media = this.activeAsset?.hls.media; + const media = this.activeAsset_?.media; if (!media) { return; } if (this.playhead === "play" || this.playhead === "playing") { media.pause(); - this.genericState_.playRequested = false; + this.state_.playRequested = false; } else { media.play(); - this.genericState_.playRequested = true; + this.state_.playRequested = true; } } @@ -244,8 +258,8 @@ export class Facade { seekTo(targetTime: number) { if (this.hls.interstitialsManager) { this.hls.interstitialsManager.primary.seekTo(targetTime); - } else if (this.hls.media) { - this.hls.media.currentTime = targetTime; + } else if (this.primaryAsset_?.media) { + this.primaryAsset_.media.currentTime = targetTime; } } @@ -254,58 +268,157 @@ export class Facade { * @param volume */ setVolume(volume: number) { - const media = this.activeAsset?.hls.media; - if (media) { - media.volume = volume; - } + // 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 `State`. + * Sets quality by id. All quality levels are defined in `qualities`. * @param id */ setQuality(height: number | null) { - this.primaryAsset?.observer.setQuality(height); + this.primaryAsset_?.observer.setQuality(height); } /** - * Sets subtitle by id. All subtitle tracks are defined in `State`. + * Sets subtitle by id. All subtitle tracks are defined in `subtitleTracks`. * @param id */ setSubtitleTrack(id: number | null) { - this.primaryAsset?.observer.setSubtitleTrack(id); + this.primaryAsset_?.observer.setSubtitleTrack(id); } /** - * Sets audio by id. All audio tracks are defined in `State`. + * Sets audio by id. All audio tracks are defined in `audioTracks`. * @param id */ setAudioTrack(id: number) { - this.primaryAsset?.observer.setAudioTrack(id); + this.primaryAsset_?.observer.setAudioTrack(id); } - private getAssetByPlayer(player: HlsAssetPlayer) { - for (const asset of this.assets_) { - if (asset.assetPlayer === player) { - return asset; - } - } - return null; + 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_); } - get primaryAsset() { - for (const asset of this.assets_) { - if (!asset.assetPlayer) { - return asset; - } + private onManifestLoaded_() { + this.state_ = {}; + this.emitter_.emit(Events.READY); + } + + 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, + type: assetListItem.type, + }; + } + + private onInterstitialAssetEnded_( + _: string, + data: InterstitialAssetEndedData, + ) { + this.interstitialAssets_.delete(data.player); + this.interstitial_ = null; + } + + 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; } - return null; } - get activeAsset() { - if (this.interstitial) { - return this.getAssetByPlayer(this.interstitial.player); + private get activeAsset_() { + if (this.interstitial_) { + return this.interstitialAssets_.get(this.interstitial_.player) ?? null; } - return this.primaryAsset; + return this.primaryAsset_; } } + +/** + * Overarching state, across all assets. + */ +type DominantState = { + started?: boolean; + playRequested?: boolean; +}; diff --git a/packages/player/src/facade/helpers.ts b/packages/player/src/facade/helpers.ts index d529c9a4..b35d9519 100644 --- a/packages/player/src/facade/helpers.ts +++ b/packages/player/src/facade/helpers.ts @@ -66,16 +66,6 @@ export function getTypes(item: InterstitialScheduleItem) { } satisfies Record); } -export function pipeState

    ( - prop: P, - asset: Asset | null, -): State[P] { - if (!asset) { - return noState[prop]; - } - return asset.state[prop]; -} - const noState: State = { playhead: "idle", started: false, @@ -87,3 +77,13 @@ const noState: State = { 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/media-manager.ts b/packages/player/src/facade/media-manager.ts new file mode 100644 index 00000000..0f126117 --- /dev/null +++ b/packages/player/src/facade/media-manager.ts @@ -0,0 +1,98 @@ +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 index 4753050a..01723215 100644 --- a/packages/player/src/facade/state-observer.ts +++ b/packages/player/src/facade/state-observer.ts @@ -3,17 +3,16 @@ import { EventManager } from "./event-manager"; import { updateActive, preciseFloat, getLang } from "./helpers"; import { assert } from "./assert"; import { Timer } from "./timer"; -import { - Events, - type AudioTrack, - type FacadeListeners, - type Quality, - type State, - type SubtitleTrack, +import { Events } from "./types"; +import type { Level, MediaPlaylist } from "hls.js"; +import type { + Playhead, + AudioTrack, + FacadeListeners, + Quality, + State, + SubtitleTrack, } from "./types"; -import type { Level } from "hls.js"; -import type { Playhead } from "./types"; -import type { MediaPlaylist } from "hls.js"; export type StateObserverEmit = ( hls: Hls, @@ -60,6 +59,11 @@ export class StateObserver { 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_() { @@ -331,7 +335,10 @@ export class StateObserver { this.state.started = true; } - this.dispatchEvent_(Events.PLAYHEAD_CHANGE, { playhead }); + this.dispatchEvent_(Events.PLAYHEAD_CHANGE, { + playhead, + started: this.state.started, + }); } private dispatchEvent_( diff --git a/packages/player/src/facade/types.ts b/packages/player/src/facade/types.ts index b96c1ce3..405a9fb5 100644 --- a/packages/player/src/facade/types.ts +++ b/packages/player/src/facade/types.ts @@ -35,10 +35,13 @@ export type Quality = { }; /** - * State of playhead across all interstitials. + * State of playhead across all assets. */ export type Playhead = "idle" | "play" | "playing" | "pause" | "ended"; +/** + * Defines an interstitial, which is not the primary content. + */ export type Interstitial = { time: number; duration: number; @@ -46,6 +49,9 @@ export type Interstitial = { type?: CustomInterstitialType; }; +/** + * State variables. + */ export type State = { playhead: Playhead; started: boolean; @@ -58,8 +64,12 @@ export type State = { subtitleTracks: SubtitleTrack[]; }; +/** + * List of events. + */ export enum Events { RESET = "reset", + READY = "ready", PLAYHEAD_CHANGE = "playheadChange", TIME_CHANGE = "timeChange", VOLUME_CHANGE = "volumeChange", @@ -71,6 +81,7 @@ export enum Events { export type PlayheadChangeEventData = { playhead: Playhead; + started: boolean; }; export type TimeChangeEventData = { @@ -98,9 +109,13 @@ export type AutoQualityChangeEventData = { autoQuality: boolean; }; +/** + * List of events with their respective event handlers. + */ export type FacadeListeners = { "*": () => 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; diff --git a/packages/player/src/react/controls/Controls.tsx b/packages/player/src/react/controls/Controls.tsx index c923bd57..f8d5cf84 100644 --- a/packages/player/src/react/controls/Controls.tsx +++ b/packages/player/src/react/controls/Controls.tsx @@ -1,17 +1,21 @@ import { Playback } from "./components/Playback"; import { Start } from "./components/Start"; -import { AppStoreProvider } from "./AppStoreProvider"; -import type { Metadata } from "./types"; +import { AppStoreProvider } from "./context/AppStoreProvider"; +import { ParamsProvider } from "./context/ParamsProvider"; +import type { Lang, Metadata } from "./types"; export type ControlsProps = { metadata?: Metadata; + lang?: Lang; }; -export function Controls({ metadata }: ControlsProps) { +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 index 02568419..63c07c2b 100644 --- a/packages/player/src/react/controls/components/BottomControls.tsx +++ b/packages/player/src/react/controls/components/BottomControls.tsx @@ -1,60 +1,42 @@ -import PlayIcon from "../icons/play.svg?react"; -import PauseIcon from "../icons/pause.svg?react"; import SettingsIcon from "../icons/settings.svg?react"; import SubtitlesIcon from "../icons/subtitles.svg?react"; import ForwardIcon from "../icons/forward.svg?react"; -import FullscreenIcon from "../icons/fullscreen.svg?react"; -import FullscreenExitIcon from "../icons/fullscreen-exit.svg?react"; import { SqButton } from "./SqButton"; import { VolumeButton } from "./VolumeButton"; import { Label } from "./Label"; import { useFacade, useSelector } from "../.."; -import { useAppStore } from "../AppStoreProvider"; +import { useAppStore } from "../hooks/useAppStore"; import { useFakeTime } from "../hooks/useFakeTime"; import { useSeekTo } from "../hooks/useSeekTo"; +import { PlayPauseButton } from "./PlayPauseButton"; +import { FullscreenButton } from "./FullscreenButton"; import type { MouseEventHandler } from "react"; import type { SetAppSettings } from "../hooks/useAppSettings"; -import type { Metadata } from "../types"; type BottomControlsProps = { nudgeVisible(): void; setAppSettings: SetAppSettings; - metadata?: Metadata; toggleFullscreen: MouseEventHandler; }; export function BottomControls({ nudgeVisible, setAppSettings, - metadata, toggleFullscreen, }: BottomControlsProps) { const facade = useFacade(); - const playhead = useSelector((facade) => facade.playhead); const interstitial = useSelector((facade) => facade.interstitial); const volume = useSelector((facade) => facade.volume); const settings = useAppStore((state) => state.settings); - const fullscreen = useAppStore((state) => state.fullscreen); const seekTo = useSeekTo(); const fakeTime = useFakeTime(); return (

    - { - facade.playOrPause(); - nudgeVisible(); - }} - > - {playhead === "play" || playhead === "playing" ? ( - - ) : ( - - )} - + { @@ -67,7 +49,7 @@ export function BottomControls({ volume={volume} setVolume={(volume) => facade.setVolume(volume)} /> -