Webamp optionally fully contained into a DOM element (#1338)

* Webamp optionally fully contained into a DOM element

* Fix spelling

* Reenable updating search index for uploads

* Replace contained flag on renderWhenReady with a new method. Add docs

---------

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>
This commit is contained in:
Lorenzo Stanco 2025-12-31 23:59:14 +01:00 committed by GitHub
parent f600fb0344
commit 162025f8a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 223 additions and 30 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules
.vscode
.idea
dist
# Turborepo cache

24
examples/contained/.gitignore vendored Normal file
View file

@ -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?

View file

@ -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.

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webamp (contained)</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -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);

1
examples/contained/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -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"]
}

View file

@ -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" };
}

View file

@ -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();

View file

@ -254,7 +254,7 @@ async function getSearchIndexes(
);
}
export async function updateSearchIndexs(
export async function updateSearchIndexes(
ctx: UserContext,
md5s: string[]
): Promise<any> {
@ -276,7 +276,7 @@ export async function updateSearchIndex(
ctx: UserContext,
md5: string
): Promise<any | null> {
return updateSearchIndexs(ctx, [md5]);
return updateSearchIndexes(ctx, [md5]);
}
export async function hideSkin(md5: string): Promise<void> {

View file

@ -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

View file

@ -229,6 +229,19 @@ Toggle repeat mode between enabled and disabled.
webamp.toggleRepeat();
```
### `renderInto(domNode: HTMLElement): Promise<void>`
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<void>`
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 `<body>` tag.

View file

@ -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

View file

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

View file

@ -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({
<ContextMenuWrapper
renderContents={() => <MainContextMenu filePickers={filePickers} />}
>
<WindowManager windows={renderWindows()} />
<WindowManager
windows={renderWindows()}
parentDomNode={parentDomNode}
/>
</ContextMenuWrapper>
</div>
</StrictMode>,

View file

@ -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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => 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]);

View file

@ -370,6 +370,16 @@ export function findLastIndex<T>(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 {

View file

@ -479,12 +479,33 @@ class Webamp {
*
* Webamp is rendered into a new DOM node at the end of the <body> 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<void> {
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<void> {
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<void> {
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}
/>
</Provider>
);