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}
/>
);