Webamp optionally fully contained into a DOM element

This commit is contained in:
Lorenzo Stanco 2025-12-22 19:44:42 +01:00
parent 61476591f8
commit 88464a0bb7
13 changed files with 189 additions and 27 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

@ -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?: false
): 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,14 @@ 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.
* A domNode must be passed, Webamp will place itself in the center of that DOM node.
*
* @param contained TRUE to render Webamp inside the passed DOM node, and to fully contain its position inside of it
*
* @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));
async renderWhenReady(node: HTMLElement, contained?: false): Promise<void> {
this.store.dispatch(Actions.centerWindowsInContainer(node, contained));
await this.skinIsLoaded();
if (this._disposable.disposed) {
return;
@ -505,13 +507,17 @@ class Webamp {
onMount = resolve;
});
if (contained && getComputedStyle(node)?.position === "static") {
node.style.position = "relative";
}
this._root.render(
<Provider store={this.store}>
<App
media={this.media}
filePickers={this.options.filePickers || []}
onMount={onMount}
parentDomNode={document.body}
parentDomNode={contained ? node : document.body}
/>
</Provider>
);