diff --git a/.gitignore b/.gitignore index 829ecf68..0f1fe805 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .vscode +.idea dist # Turborepo cache diff --git a/examples/contained/.gitignore b/examples/contained/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/contained/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/contained/README.md b/examples/contained/README.md new file mode 100644 index 00000000..c767aa54 --- /dev/null +++ b/examples/contained/README.md @@ -0,0 +1,3 @@ +# `webamp/contained` Example + +Example for Webamp fully contained into a DOM element. Uses [Vite](https://vitejs.dev/) for development and bundling. diff --git a/examples/contained/index.html b/examples/contained/index.html new file mode 100644 index 00000000..99441f43 --- /dev/null +++ b/examples/contained/index.html @@ -0,0 +1,13 @@ + + + + + + + Webamp (contained) + + +
+ + + diff --git a/examples/contained/package.json b/examples/contained/package.json new file mode 100644 index 00000000..62b308a5 --- /dev/null +++ b/examples/contained/package.json @@ -0,0 +1,18 @@ +{ + "name": "contained", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.4" + }, + "dependencies": { + "webamp": "^2.2.0" + } +} diff --git a/examples/contained/src/main.ts b/examples/contained/src/main.ts new file mode 100644 index 00000000..523b6e2f --- /dev/null +++ b/examples/contained/src/main.ts @@ -0,0 +1,36 @@ +import Webamp from "../../../packages/webamp/js/webamp"; + +const webamp = new Webamp({ + initialTracks: [ + { + metaData: { + artist: "DJ Mike Llama", + title: "Llama Whippin' Intro", + }, + // NOTE: Your audio file must be served from the same domain as your HTML + // file, or served with permissive CORS HTTP headers: + // https://docs.webamp.org/docs/guides/cors + url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3", + duration: 5.322286, + }, + ], + windowLayout: { + main: { position: { left: 0, top: 0 } }, + equalizer: { position: { left: 0, top: 116 } }, + playlist: { + position: { left: 0, top: 232 }, + size: { extraHeight: 4, extraWidth: 0 }, + }, + }, +}); + +// Container smaller than body +const container = document.getElementById("app"); +container!.style.position = "absolute"; +container!.style.left = "20px"; +container!.style.top = "20px"; +container!.style.right = "120px"; +container!.style.bottom = "120px"; +container!.style.backgroundColor = "lightyellow"; + +webamp.renderWhenReady(container!, true); diff --git a/examples/contained/src/vite-env.d.ts b/examples/contained/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/contained/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/contained/tsconfig.json b/examples/contained/tsconfig.json new file mode 100644 index 00000000..4f5edc24 --- /dev/null +++ b/examples/contained/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/skin-database/addSkin.ts b/packages/skin-database/addSkin.ts index 1d879d44..10d45cca 100644 --- a/packages/skin-database/addSkin.ts +++ b/packages/skin-database/addSkin.ts @@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer( await setHashesForSkin(skin); // Disable while we figure out our quota - // await Skins.updateSearchIndex(ctx, md5); + await Skins.updateSearchIndex(ctx, md5); return { md5, status: "ADDED", skinType: "CLASSIC" }; } diff --git a/packages/skin-database/cli.ts b/packages/skin-database/cli.ts index b34efb6f..19e7ee5d 100755 --- a/packages/skin-database/cli.ts +++ b/packages/skin-database/cli.ts @@ -458,7 +458,7 @@ program ); const md5s = rows.map((row) => row.md5); console.log(md5s.length); - console.log(await Skins.updateSearchIndexs(ctx, md5s)); + console.log(await Skins.updateSearchIndexes(ctx, md5s)); } if (refreshContentHash) { const ctx = new UserContext(); diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts index 3ab4c4c2..02249709 100644 --- a/packages/skin-database/data/skins.ts +++ b/packages/skin-database/data/skins.ts @@ -254,7 +254,7 @@ async function getSearchIndexes( ); } -export async function updateSearchIndexs( +export async function updateSearchIndexes( ctx: UserContext, md5s: string[] ): Promise { @@ -276,7 +276,7 @@ export async function updateSearchIndex( ctx: UserContext, md5: string ): Promise { - return updateSearchIndexs(ctx, [md5]); + return updateSearchIndexes(ctx, [md5]); } export async function hideSkin(md5: string): Promise { diff --git a/packages/webamp-docs/docs/03_initialization.md b/packages/webamp-docs/docs/03_initialization.md index 2d64ac4b..2541c189 100644 --- a/packages/webamp-docs/docs/03_initialization.md +++ b/packages/webamp-docs/docs/03_initialization.md @@ -12,6 +12,8 @@ Create a DOM element somewhere in your HTML document. This will be used by Webam :::tip **Webamp will not actually insert itself as a child of this element.** It will will insert itself as a child of the body element, and will attempt to center itself within this element. This is needed to allow the various Webamp windows to dragged around the page unencumbered. + +If you want Webamp to be a child of a specific element, use the [`renderInto(domNode)`](./06_API/03_instance-methods.md#renderintodomnode-htmlelement-promisevoid) method instead. ::: ## Initialize Webamp instance diff --git a/packages/webamp-docs/docs/06_API/03_instance-methods.md b/packages/webamp-docs/docs/06_API/03_instance-methods.md index 32fa1cbe..9493beca 100644 --- a/packages/webamp-docs/docs/06_API/03_instance-methods.md +++ b/packages/webamp-docs/docs/06_API/03_instance-methods.md @@ -229,6 +229,19 @@ Toggle repeat mode between enabled and disabled. webamp.toggleRepeat(); ``` +### `renderInto(domNode: HTMLElement): Promise` + +Webamp will wait until it has fetched the skin and fully parsed it, and then render itself as a child of the provided `domNode` and position itself in the center of that DOM node. + +A promise is returned which will resolve after the render is complete. + +```ts +const container = document.getElementById("webamp-container"); +webamp.renderWhenReady(container).then(() => { + console.log("rendered webamp!"); +}); +``` + ### `renderWhenReady(domNode: HTMLElement): Promise` Webamp will wait until it has fetched the skin and fully parsed it, and then render itself into a new DOM node at the end of the `` tag. diff --git a/packages/webamp-docs/docs/12_changelog.md b/packages/webamp-docs/docs/12_changelog.md index 418e6e74..f4b8c9a4 100644 --- a/packages/webamp-docs/docs/12_changelog.md +++ b/packages/webamp-docs/docs/12_changelog.md @@ -10,6 +10,7 @@ If you want access to the changes in this section before they are officially rel ### Improvements +- Added a new Webamp instance method: [`webamp.renderInto(domNode)`](./06_API/03_instance-methods.md#renderintodomnode-htmlelement-promisevoid). Which renders Webamp as a _child_ of the provided DOM node, rather than within its own top-level container. - Added new [`requireButterchurnPresets`](./06_API/02_webamp-constructor.md#requirebutterchurnpresets---promisepreset) option when constructing a Webamp instance. This allows you to specify which Butterchurn presets to use for the Milkdrop visualizer. If you don't specify this option, Webamp will use the default Butterchurn presets. ### Bug Fixes diff --git a/packages/webamp/js/actionCreators/windows.ts b/packages/webamp/js/actionCreators/windows.ts index 2a4ce8a2..e0921082 100644 --- a/packages/webamp/js/actionCreators/windows.ts +++ b/packages/webamp/js/actionCreators/windows.ts @@ -104,20 +104,27 @@ export function updateWindowPositions( return { type: "UPDATE_WINDOW_POSITIONS", positions, absolute }; } -export function centerWindowsInContainer(container: HTMLElement): Thunk { +export function centerWindowsInContainer( + container: HTMLElement, + contained: boolean +): Thunk { return (dispatch, getState) => { if (!Selectors.getPositionsAreRelative(getState())) { return; } - const { left, top } = container.getBoundingClientRect(); + const { left, top } = contained + ? { left: 0, top: 0 } + : container.getBoundingClientRect(); const { scrollWidth: width, scrollHeight: height } = container; dispatch(centerWindows({ left, top, width, height })); }; } -export function centerWindowsInView(): Thunk { - const height = window.innerHeight; - const width = window.innerWidth; +export function centerWindowsInView(parentDomNode?: HTMLElement): Thunk { + const { width, height } = + parentDomNode === document.body || !parentDomNode + ? { width: window.innerWidth, height: window.innerHeight } + : Utils.getElementSize(parentDomNode); return centerWindows({ left: 0, top: 0, width, height }); } @@ -168,13 +175,16 @@ export function centerWindows({ left, top, width, height }: Box): Thunk { }; } -export function browserWindowSizeChanged(size: { - height: number; - width: number; -}): Thunk { +export function browserWindowSizeChanged( + size: { + height: number; + width: number; + }, + parentDomNode?: HTMLElement +): Thunk { return (dispatch: Dispatch) => { dispatch({ type: "BROWSER_WINDOW_SIZE_CHANGED", ...size }); - dispatch(ensureWindowsAreOnScreen()); + dispatch(ensureWindowsAreOnScreen(parentDomNode)); }; } @@ -234,13 +244,16 @@ export function setWindowLayout(layout?: WindowLayout): Thunk { }; } -export function ensureWindowsAreOnScreen(): Thunk { +export function ensureWindowsAreOnScreen(parentDomNode?: HTMLElement): Thunk { return (dispatch, getState) => { const state = getState(); const windowsInfo = Selectors.getWindowsInfo(state); const getOpen = Selectors.getWindowOpen(state); - const { height, width } = Utils.getWindowSize(); + const { height, width } = + parentDomNode === document.body || !parentDomNode + ? Utils.getWindowSize() + : Utils.getElementSize(parentDomNode); const bounding = Utils.calculateBoundingBox( windowsInfo.filter((w) => getOpen(w.key)) ); @@ -294,6 +307,6 @@ export function ensureWindowsAreOnScreen(): Thunk { // I give up. Just reset everything. dispatch(resetWindowSizes()); dispatch(stackWindows()); - dispatch(centerWindowsInView()); + dispatch(centerWindowsInView(parentDomNode)); }; } diff --git a/packages/webamp/js/components/App.tsx b/packages/webamp/js/components/App.tsx index 2a6926e9..31cba88b 100644 --- a/packages/webamp/js/components/App.tsx +++ b/packages/webamp/js/components/App.tsx @@ -87,7 +87,7 @@ export default function App({ webampNode.style.right = "0"; webampNode.style.bottom = "0"; webampNode.style.overflow = "hidden"; - browserWindowSizeChanged(Utils.getWindowSize()); + browserWindowSizeChanged(Utils.getWindowSize(), parentDomNode); webampNode.style.right = "auto"; webampNode.style.bottom = "auto"; webampNode.style.overflow = "visible"; @@ -100,7 +100,7 @@ export default function App({ return () => { window.removeEventListener("resize", handleWindowResize); }; - }, [browserWindowSizeChanged, webampNode]); + }, [parentDomNode, browserWindowSizeChanged, webampNode]); useEffect(() => { if (onMount != null) { @@ -151,7 +151,10 @@ export default function App({ } > - + , diff --git a/packages/webamp/js/components/WindowManager.tsx b/packages/webamp/js/components/WindowManager.tsx index 4f7418c1..b90a2e94 100644 --- a/packages/webamp/js/components/WindowManager.tsx +++ b/packages/webamp/js/components/WindowManager.tsx @@ -16,6 +16,7 @@ const abuts = (a: Box, b: Box) => { interface Props { windows: { [windowId: string]: ReactNode }; + parentDomNode: HTMLElement; } type DraggingState = { @@ -25,9 +26,12 @@ type DraggingState = { mouseStart: Point; }; -function useHandleMouseDown(propsWindows: { - [windowId: string]: ReactNode; -}): ( +function useHandleMouseDown( + propsWindows: { + [windowId: string]: ReactNode; + }, + parentDomNode: HTMLElement +): ( key: WindowId, e: React.MouseEvent | React.TouchEvent ) => void { @@ -69,7 +73,9 @@ function useHandleMouseDown(propsWindows: { const withinDiff = SnapUtils.snapWithinDiff( proposedBox, - browserWindowSize + parentDomNode === document.body || !parentDomNode + ? browserWindowSize + : Utils.getElementSize(parentDomNode) ); const finalDiff = SnapUtils.applyMultipleDiffs( @@ -102,7 +108,7 @@ function useHandleMouseDown(propsWindows: { window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("touchend", handleMouseUp); }; - }, [browserWindowSize, draggingState, updateWindowPositions]); + }, [parentDomNode, browserWindowSize, draggingState, updateWindowPositions]); // Mouse down handler return useCallback( @@ -149,10 +155,13 @@ function useHandleMouseDown(propsWindows: { ); } -export default function WindowManager({ windows: propsWindows }: Props) { +export default function WindowManager({ + windows: propsWindows, + parentDomNode, +}: Props) { const windowsInfo = useTypedSelector(Selectors.getWindowsInfo); const setFocusedWindow = useActionCreator(Actions.setFocusedWindow); - const handleMouseDown = useHandleMouseDown(propsWindows); + const handleMouseDown = useHandleMouseDown(propsWindows, parentDomNode); const windows = windowsInfo.filter((w) => propsWindows[w.key]); diff --git a/packages/webamp/js/utils.ts b/packages/webamp/js/utils.ts index 732528c8..22ef4dd2 100644 --- a/packages/webamp/js/utils.ts +++ b/packages/webamp/js/utils.ts @@ -370,6 +370,16 @@ export function findLastIndex(arr: T[], cb: (val: T) => boolean) { return -1; } +export function getElementSize(domNode: HTMLElement): { + width: number; + height: number; +} { + return { + width: Math.max(domNode.scrollWidth, domNode.offsetWidth), + height: Math.max(domNode.scrollHeight, domNode.offsetHeight), + }; +} + export function getWindowSize(): { width: number; height: number } { // Apparently this is crazy across browsers. return { diff --git a/packages/webamp/js/webampLazy.tsx b/packages/webamp/js/webampLazy.tsx index 2078e514..2d6acc79 100644 --- a/packages/webamp/js/webampLazy.tsx +++ b/packages/webamp/js/webampLazy.tsx @@ -479,12 +479,33 @@ class Webamp { * * Webamp is rendered into a new DOM node at the end of the tag with the id `#webamp`. * - * If a domNode is passed, Webamp will place itself in the center of that DOM node. + * Webamp will position itself on top of the center of the given DOM node. * * @returns A promise is returned which will resolve after the render is complete. */ async renderWhenReady(node: HTMLElement): Promise { - this.store.dispatch(Actions.centerWindowsInContainer(node)); + return this._render(node, false); + } + + /** + * Webamp will wait until it has fetched the skin and fully parsed it and then render itself. + * + * Webamp will render itself as a child of the given DOM node and position + * itself in the center of that node. + * + * @returns A promise is returned which will resolve after the render is complete. + */ + async renderInto(node: HTMLElement): Promise { + if (getComputedStyle(node)?.position === "static") { + throw new Error( + "Webamp Error: The DOM node passed to renderInto must have a non-static position." + ); + } + return this._render(node, true); + } + + async _render(node: HTMLElement, contained: boolean): Promise { + this.store.dispatch(Actions.centerWindowsInContainer(node, contained)); await this.skinIsLoaded(); if (this._disposable.disposed) { return; @@ -511,7 +532,7 @@ class Webamp { media={this.media} filePickers={this.options.filePickers || []} onMount={onMount} - parentDomNode={document.body} + parentDomNode={contained ? node : document.body} /> );