Skip to content

Commit

Permalink
feat(web): initial web video support
Browse files Browse the repository at this point in the history
  • Loading branch information
aidenlx committed Dec 29, 2023
1 parent 8d94e8f commit e27cfbb
Show file tree
Hide file tree
Showing 32 changed files with 2,004 additions and 32 deletions.
84 changes: 79 additions & 5 deletions apps/app/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import obPlugin from "./scripts/ob.esbuild.mjs";
import { build, context } from "esbuild";
import stylePlugin from "esbuild-style-plugin";
import { readFileSync } from "fs";
import { join, resolve } from "path";
import { resolve, basename } from "path";
import semverPrerelease from "semver/functions/prerelease.js";
import NODE_BULTIIN from "builtin-modules";
import { readFile } from "fs/promises";
Expand Down Expand Up @@ -48,6 +48,8 @@ const CM_BULTIIN = [

const isProd = process.env?.BUILD === "production";

const randomUid = Math.random().toString(36).slice(2);

/** @type import("esbuild").BuildOptions */
const opts = {
bundle: true,
Expand All @@ -57,8 +59,9 @@ const opts = {
minify: isProd,
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.BUILD ?? ""),
"__DEV__": JSON.stringify(!isProd),
},
logLevel: process.env.BUILD === "development" ? "info" : "silent",
logLevel: isProd ? "silent" : "info",
external: [
"obsidian",
"electron",
Expand All @@ -81,6 +84,20 @@ const opts = {
postcssConfigFile: resolve("./postcss.config.mjs"),
}),
obPlugin({ beta: isPreRelease() }),
inlineCodePlugin(
{
...(isProd
? {
define: {
// prevent global variable from being detected by website
"window.MX_MESSAGE": `window._${randomUid}`,
MX_MESSAGE: `_${randomUid}`,
},
drop: ["console"],
}
: {})
}
),
{
name: "vidstack-loader",
setup: (build) => {
Expand All @@ -94,9 +111,9 @@ const opts = {
),
loader: "js",
};
})
}
}
});
},
},
],
};

Expand All @@ -118,3 +135,60 @@ if (!isProd) {
} else {
await build(opts);
}

/**
*
* @param {Partial<import("esbuild").BuildOptions>} extraConfig
* @returns {import("esbuild").Plugin}
*/
function inlineCodePlugin(extraConfig) {
return {
name: "inline-code",
setup: (build) => {
const codePrefixPattern = new RegExp(`^inline:`),
namespace = "inline";
build.onResolve(
{ filter: codePrefixPattern },
({ path: workerPath, resolveDir }) => {
return {
path: resolve(
resolveDir,
workerPath.replace(codePrefixPattern, "")
),
namespace,
};
}
);
build.onLoad(
{ filter: /.*/, namespace },
async ({ path: workerPath }) => {
return {
contents: await buildWorker(workerPath, extraConfig),
loader: "text",
};
}
);
build.onEnd(() => console.log("inline code built"));
},
};
}

async function buildWorker(
workerPath,
{ entryPoints, outfile, outdir, ...extraConfig }
) {
const scriptName = basename(workerPath).replace(/\.[^.]*$/, ".js");

const result = await build({
entryPoints: [workerPath],
write: false, // write in memory
outfile: scriptName,
bundle: true,
minify: true,
format: "esm",
target: "es2022",
...extraConfig,
});

return `(async function(){${new TextDecoder("utf-8").decode(result.outputFiles[0].contents)}})()`;
}
7 changes: 5 additions & 2 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
"lint": "eslint src/ --ext .ts,.tsx --fix"
},
"devDependencies": {
"@electron/remote": "^2.1.1",
"@mx/config": "workspace:*",
"@types/eslint": "~8.4.6",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"autoprefixer": "^10.4.16",
"builtin-modules": "^3.3.0",
"cross-env": "^7.0.3",
"electron": "^25.0.0",
"electron": "^25.9.8",
"esbuild": "^0.19.9",
"esbuild-style-plugin": "^1.6.3",
"eslint": "~8.56.0",
Expand All @@ -33,14 +34,16 @@
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.6",
"@vidstack/react": "^1.9.8",
"@vidstack/react": "npm:@aidenlx/vidstack-react@1.9.8-mod.10",
"ahooks": "^3.7.8",
"clsx": "^1.2.1",
"maverick.js": "0.41.2",
"monkey-around": "^2.3.0",
"nanoevents": "^9.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tailwind-merge": "^2.1.0",
"use-callback-ref": "^1.3.1",
"zustand": "^4.4.7"
}
}
5 changes: 3 additions & 2 deletions apps/app/src/components/player.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import "@vidstack/react/player/styles/base.css";

import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react";
import { MediaPlayer, useMediaState } from "@vidstack/react";

import { cn } from "@/lib/utils";
import { useMediaViewStore } from "./context";
import { useViewTypeDetect } from "./fix-webm-audio";
import { AudioLayout } from "./player/layouts/audio-layout";
import { VideoLayout } from "./player/layouts/video-layout";
import { MediaProviderEnhanced } from "./provider";
import { useHandleWindowMigration } from "./use-window-migration";

export function Player() {
Expand Down Expand Up @@ -34,7 +35,7 @@ export function Player() {
viewType={viewType}
ref={playerRef}
>
<MediaProvider />
<MediaProviderEnhanced />
{actualViewType === "video" ? <VideoLayout /> : <AudioLayout />}
</MediaPlayer>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/player/layouts/video-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function VideoLayout({ thumbnails }: VideoLayoutProps) {
<Captions
className={`mx-captions media-preview:opacity-0 media-controls:bottom-[85px] media-captions:opacity-100 absolute inset-0 bottom-2 z-10 select-none break-words opacity-0 transition-[opacity,bottom] duration-300`}
/>
<Controls.Root className="media-controls:opacity-100 absolute inset-0 z-10 flex h-full w-full flex-col bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity">
<Controls.Root className="media-controls:opacity-100 absolute inset-0 z-10 flex h-full w-full flex-col bg-gradient-to-t from-black/10 to-transparent opacity-100 transition-opacity">
<Tooltip.Provider>
<div className="flex-1" />
<Controls.Group className="flex w-full items-center px-2">
Expand Down
40 changes: 40 additions & 0 deletions apps/app/src/components/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
MediaProvider,
type MediaProviderAdapter,
type MediaProviderInstance,
type MediaProviderLoader,
type MediaProviderProps,
} from "@vidstack/react";
import { useCallback } from "react";
import { WebviewProviderLoader } from "@/lib/remote-player/loader";
import { WebView } from "./webview";

export function MediaProviderEnhanced({
loaders,
...props
}: Omit<MediaProviderProps, "buildMediaEl">) {
return (
<MediaProvider
loaders={[WebviewProviderLoader, ...(loaders ?? [])]}
buildMediaEl={useCallback(
(
loader: MediaProviderLoader<MediaProviderAdapter> | null,
provider: MediaProviderInstance,
) => {
if (!(loader instanceof WebviewProviderLoader)) return null;
return (
<WebView
aria-hidden
devtools
ref={(inst) => {
provider.load(inst);
}}
/>
);
},
[],
)}
{...props}
/>
);
}
122 changes: 122 additions & 0 deletions apps/app/src/components/webview/events.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect } from "react";

export const WebviewEventsMap = {
onLoadCommit: "load-commit",
onDidFinishLoad: "did-finish-load",
onDidFailLoad: "did-fail-load",
onDidFrameFinishLoad: "did-frame-finish-load",
onDidStartLoading: "did-start-loading",
onDidStopLoading: "did-stop-loading",
onDidAttach: "did-attach",
onDomReady: "dom-ready",
onPageTitleUpdated: "page-title-updated",
onPageFaviconUpdated: "page-favicon-updated",
onEnterHtmlFullScreen: "enter-html-full-screen",
onLeaveHtmlFullScreen: "leave-html-full-screen",
onConsoleMessage: "console-message",
onFoundInPage: "found-in-page",
onWillNavigate: "will-navigate",
onDidStartNavigation: "did-start-navigation",
onDidRedirectNavigation: "did-redirect-navigation",
onDidNavigate: "did-navigate",
onDidFrameNavigate: "did-frame-navigate",
onDidNavigateInPage: "did-navigate-in-page",
onClose: "close",
onIpcMessage: "ipc-message",
onCrashed: "crashed",
onPluginCrashed: "plugin-crashed",
onDestroyed: "destroyed",
onMediaStartedPlaying: "media-started-playing",
onMediaPaused: "media-paused",
onDidChangeThemeColor: "did-change-theme-color",
onUpdateTargetUrl: "update-target-url",
onDevtoolsOpened: "devtools-opened",
onDevtoolsClosed: "devtools-closed",
onDevtoolsFocused: "devtools-focused",
onContextMenu: "context-menu",
} satisfies Record<keyof WebviewEvents, string>;

interface WebviewEvents {
onLoadCommit: Electron.LoadCommitEvent;
onDidFinishLoad: Electron.Event;
onDidFailLoad: Electron.DidFailLoadEvent;
onDidFrameFinishLoad: Electron.DidFrameFinishLoadEvent;
onDidStartLoading: Electron.Event;
onDidStopLoading: Electron.Event;
onDidAttach: Electron.Event;
onDomReady: Electron.Event;
onPageTitleUpdated: Electron.PageTitleUpdatedEvent;
onPageFaviconUpdated: Electron.PageFaviconUpdatedEvent;
onEnterHtmlFullScreen: Electron.Event;
onLeaveHtmlFullScreen: Electron.Event;
onConsoleMessage: Electron.ConsoleMessageEvent;
onFoundInPage: Electron.FoundInPageEvent;
onWillNavigate: Electron.WillNavigateEvent;
onDidStartNavigation: Electron.DidStartNavigationEvent;
onDidRedirectNavigation: Electron.DidRedirectNavigationEvent;
onDidNavigate: Electron.DidNavigateEvent;
onDidFrameNavigate: Electron.DidFrameNavigateEvent;
onDidNavigateInPage: Electron.DidNavigateInPageEvent;
onClose: Electron.Event;
onIpcMessage: Electron.IpcMessageEvent;
onCrashed: Electron.Event;
onPluginCrashed: Electron.PluginCrashedEvent;
onDestroyed: Electron.Event;
onMediaStartedPlaying: Electron.Event;
onMediaPaused: Electron.Event;
onDidChangeThemeColor: Electron.DidChangeThemeColorEvent;
onUpdateTargetUrl: Electron.UpdateTargetUrlEvent;
onDevtoolsOpened: Electron.Event;
onDevtoolsClosed: Electron.Event;
onDevtoolsFocused: Electron.Event;
onContextMenu: Electron.ContextMenuEvent;
}

export type WebviewEventProps = Partial<{
[K in keyof WebviewEvents]: (event: WebviewEvents[K]) => void;
}>;

function pickEvents<P extends WebviewEventProps>(props: P) {
type RestProps = Omit<P, keyof WebviewEvents>;
return Object.entries(props).reduce(
(acc, [key, value]) => {
if (key in WebviewEventsMap) {
acc.event[key as keyof WebviewEventProps] = value as any;
} else {
acc.rest[key as keyof RestProps] = value as any;
}
return acc;
},
{
event: {},
rest: {},
} as {
event: WebviewEventProps;
rest: RestProps;
},
);
}

export function useEvents<P extends WebviewEventProps>(
props: P,
ref: React.RefObject<Electron.WebviewTag>,
) {
const { event, rest } = pickEvents(props);
for (const k of Object.keys(WebviewEventsMap)) {
const propName = k as keyof typeof WebviewEventsMap,
eventName = WebviewEventsMap[propName];

const handler = event[propName] as any;
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!ref.current || !handler) return;

const element = ref.current;
element.addEventListener(eventName, handler);
return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, ref]);
}
return rest;
}
Loading

0 comments on commit e27cfbb

Please sign in to comment.