From 20706fb9a4ec646816b44a10842aa8f58ea0fd75 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 11:01:02 +0200 Subject: music: play, play next, add to queue The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 --- .../meshbay-hub/src/meshbay_hub/static/menu.js | 171 +++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/menu.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js new file mode 100644 index 0000000..4aa08ca --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js @@ -0,0 +1,171 @@ +import { + html, useState, useEffect, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { Icon } from './icon.js'; + +/** + * A pop-up menu, opened at a point — the shared component the media views use + * for "what do you want to do with this album" (docs/playlists.md §10.1). + * + * Two affordances, both always present, because neither covers everyone: + * right-click is what a desktop reader reaches for and leaves no chrome on a + * grid of covers, and a dots button is the only thing that exists on a + * touchscreen. `openAt` takes either event and needs to know which. + * + * **Submenus expand in place** rather than flying out sideways. The account + * menu's language list already does this (`app.js`), and a flyout is the one + * shape that cannot work here: the longest submenu is a playlist's tracklist, + * and at a phone's 400px there is nowhere for a second panel to go. Expanding + * downward needs no flipping, no hover intent, and no separate mobile design. + * + * `items` is a flat list, each entry one of: + * { label, icon?, onSelect } an action + * { label, icon?, items, empty? } a submenu, expanded in place + * { divider: true } + */ + +// Kept away from the viewport edges; `.ctx-menu` sets the width this assumes. +const MARGIN = 8; + +function useMenu() { + const [menu, setMenu] = useState(null); // { x, y, items } + + const close = useCallback(() => setMenu(null), []); + + /** + * Open at `e`. A `contextmenu` event carries the pointer; a click on a dots + * button carries only the button, so the menu hangs off its bottom-right — + * read synchronously, because `currentTarget` is null once the handler has + * returned. + */ + const openAt = useCallback((e, items) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'contextmenu') { + setMenu({ x: e.clientX, y: e.clientY, items }); + return; + } + const r = e.currentTarget.getBoundingClientRect(); + setMenu({ x: r.right, y: r.bottom + 4, items }); + }, []); + + return { menu, openAt, close }; +} + +function MenuItems({ items, depth, onClose }) { + const [expanded, setExpanded] = useState(null); + + return html` + ${items.map((it, i) => { + if (it.divider) return html`
`; + + if (it.items) { + const open = expanded === i; + return html` + + ${open && (it.items.length + ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />` + : html`
${it.empty || ''}
`)} + `; + } + + return html` + + `; + })} + `; +} + +function Menu({ x, y, items, onClose }) { + const ref = useRef(null); + const [place, setPlace] = useState(null); + + // Measure, then place. A menu opened near the right or bottom edge has to + // flip rather than be clipped, and how tall it is depends on its own items — + // so the number cannot be written down, it has to be read. + // + // This is not the mutate-then-measure loop CLAUDE.md records: the effect + // writes `place`, which moves the panel, and `place` is not in its + // dependencies. Position does not change the size being measured, so it + // cannot wake itself. + useEffect(() => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const left = Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)); + const top = (y + r.height > window.innerHeight - MARGIN) + ? Math.max(MARGIN, y - r.height) + : y; + setPlace({ left, top }); + // eslint-disable-next-line + }, [x, y, items]); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + // `mousedown`, not `click`: the click that follows the right-click which + // opened this would otherwise close it again before anything is drawn. + const onDown = (e) => { + if (ref.current && !ref.current.contains(e.target)) onClose(); + }; + // Scroll closes it, and this one is not optional. The media toolbars are + // sticky bands and the grid scrolls underneath them, so a menu that + // survives a scroll is a menu still pointing at the album it was opened + // on while sitting over a completely different one. + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + window.addEventListener('scroll', onClose, true); + window.addEventListener('resize', onClose); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onDown); + window.removeEventListener('scroll', onClose, true); + window.removeEventListener('resize', onClose); + }; + }, [onClose]); + + return html` + + `; +} + +/** + * The dots button. A separate export because every call site needs the same + * one and because it must be a real ` + `; +} + +export { Menu, MenuDots, useMenu }; -- cgit v1.2.3