From df7eb105dda550989dccd3ba2bf22a50e919b19b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 22:44:27 +0200 Subject: feat(hub): audio_root wiring, mutually-exclusive players, Settings rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five related pieces of polish against the Music app and Settings, all from the same conversation: - Music app now requires audio_root, same as Videos requires video_root: an empty-state message until one is set, and grouping filtered to only what's under it (underAudioRoot, mirroring video-app.js's underVideoRoot). Wires the new audio_root/audio_root_ack pair through transport.js and group-page.js state the same way video_root already flows. - Starting one player now stops the other — opening a film closes the music queue, starting a track closes the video modal. Both used to run at once, found live. - Group Settings reworked: every section but a bare form (invite, pair-operator, approve-device) is now collapsible (CollapsibleSection); the uploads on/off button is a real toggle switch (ToggleSwitch, reused for TMDB/MusicBrainz's enabled switches too, each now with an icon + status badge in its header instead of a plain checkbox row); and shared directories, the Videos root picker, and the new Music root picker are merged into one "Directories" section (RootFolderRow) instead of three separate ones scattered down the page — the root pickers only show once their app is actually enabled. --- .../src/meshbay_hub/static/group-settings.js | 287 ++++++++++++++------- 1 file changed, 199 insertions(+), 88 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 03162d9..62665a8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -17,6 +17,89 @@ const TMDB_LANGUAGE_BY_LOCALE = { ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL', }; +/** + * A settings-section that folds — every section but the ones that are + * really just a form to fill in (invite, pair-operator, approve-device): + * hiding an input the operator is mid-typing-into behind a click they'd + * have to undo is friction with nothing to show for it, but a section that + * is only ever glanced at once it's configured (TMDB, scan tuning, the + * danger zone) benefits from staying out of the way otherwise. `title` (an + * already-built string/vnode) wins over `titleKey` when both are given — + * the members-table heading needs a live count baked in, not just a + * lookup. + */ +function CollapsibleSection({ titleKey, title, defaultOpen = true, children }) { + const [open, setOpen] = useState(defaultOpen); + return html` +
+ + ${open && html`
${children}
`} +
+ `; +} + +/** + * A modern on/off switch — replaces a plain checkbox or a "Turn on/off" + * button wherever the setting itself is a straight binary (uploads + * allowed, TMDB/MusicBrainz enabled). Still a real + * under the hood (keyboard/screen-reader behaviour for free), just + * restyled — see .toggle-switch in style.css. + */ +function ToggleSwitch({ checked, onChange, disabled, label }) { + return html` + + `; +} + +/** + * Which folder is an app's entry point for this group — the shared shape + * behind both the Videos and Music root pickers (docs/musicbay.md's + * amended §2.1): a depth-indented onDraftChange(e.target.value)}> + + ${folders.map(p => html` + + `)} + + + + + ${msg && html`

${msg}

`} + + `; +} + // ── Members Panel ──────────────────────────────────────────────────────── /** @@ -36,6 +119,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, tmdbConfig, onTmdbConfig, onTmdbEnabled, musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled, entries, nodeDirs, videoRoot, onVideoRoot, + audioRoot, onAudioRoot, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); @@ -431,9 +515,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // `entries[].path` is each file's containing directory (files-app.js's own // convention), so every ancestor prefix of it is a real folder, and // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented - // rather than a live folder browser: choosing an app's root is a + // rare, one-off decision, not something worth a whole navigable tree for. + // Shared between the Videos and Music root pickers below — same folder + // set either way. + const rootFolderOptions = useMemo(() => { const set = new Set(); const addAncestors = (path) => { if (!path) return; @@ -486,6 +572,39 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]); + // Same shape as the Videos root above — the Music app's own entry point + // (docs/musicbay.md's amended §2.1). + const [audioRootDraft, setAudioRootDraft] = useState(audioRoot || ''); + useEffect(() => { setAudioRootDraft(audioRoot || ''); }, [audioRoot]); + const [audioRootBusy, setAudioRootBusy] = useState(false); + const [audioRootMsg, setAudioRootMsg] = useState(''); + + const saveAudioRoot = useCallback(async () => { + const next = audioRootDraft; + const current = audioRoot || ''; + if (next === current) return; + if (current && !confirm(t('settings_node.audio_root_change_confirm'))) return; + const transport = transportRef && transportRef.current; + setAudioRootMsg(''); + setAudioRootBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setAudioRoot(next, signFn); + if (onAudioRoot) onAudioRoot(next); + setAudioRootMsg(t('settings_node.scan_saved')); + } catch (err) { + setAudioRootMsg(err.message); + } finally { + setAudioRootBusy(false); + } + }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]); + const [removing, setRemoving] = useState(''); /** @@ -655,8 +774,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, Photos) show up here automatically as they register in apps.js — nothing about this section changes to add one. */ isNodeAdmin && connected && html` -
-

${t('members.apps_title')}

+ <${CollapsibleSection} titleKey="members.apps_title">

${t('members.apps_hint')}

${appsMsg && html`

${appsMsg}

`} -
+ `} ${/* How hard the node works watching its own disk — indexer.py DirectoryIndexer. A performance knob, not a permission: it changes nothing about who can see or do what. */ isNodeAdmin && connected && html` -
-

${t('settings_node.scan_title')}

+ <${CollapsibleSection} titleKey="settings_node.scan_title" defaultOpen=${false}>

${t('settings_node.scan_hint')}

+ `} ${/* The on/off switch is per-group (2026-08-24); the custom token and @@ -712,15 +829,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, signed operator settings, not display preferences — but two independent ones now, saved separately. */ isNodeAdmin && connected && html` -
-

${t('settings_node.tmdb_title')}

+ <${CollapsibleSection} defaultOpen=${false} title=${html` + + <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')} + + ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} + + + `}>

${t('settings_node.tmdb_hint')}

- + <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy} + onChange=${(v) => saveTmdbEnabled(v)} + label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
+ `} ${/* Same two-part shape as TMDB above: the on/off switch is per-group, @@ -763,15 +884,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, there is no token field: MusicBrainz's read endpoints need no credential, just a descriptive User-Agent contact. */ isNodeAdmin && connected && html` -
-

${t('settings_node.musicbrainz_title')}

+ <${CollapsibleSection} defaultOpen=${false} title=${html` + + <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')} + + ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} + + + `}>

${t('settings_node.musicbrainz_hint')}

- + <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy} + onChange=${(v) => saveMusicbrainzEnabled(v)} + label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
+ `} ${/* Which folder is the Videos app's entry point for this group — @@ -799,36 +924,36 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, §5.6). Until one is chosen, the Videos tab says so instead of listing anything, and the node runs no TMDB/thumbnail work for this group at all (daemon.py's _enrich_new_video_entries). */ - isNodeAdmin && connected && html` -
-

${t('settings_node.video_root_title')}

-

${t('settings_node.video_root_hint')}

-
- -
- - ${videoRootMsg && html`

${videoRootMsg}

`} -
- `} + isNodeAdmin && connected + && ((nodeDetected && nodeRoots.length > 0) + || activeApps.includes('video') || activeApps.includes('music')) && html` + <${CollapsibleSection} titleKey="settings_node.directories_title"> +

${t('settings_node.directories_hint')}

- ${/* Roots management (Electron-only, when node is local) */ + ${activeApps.includes('video') && html` + <${RootFolderRow} icon="video" + titleKey="settings_node.video_root_title" hintKey="settings_node.video_root_hint" + folders=${rootFolderOptions} value=${videoRoot} + draft=${videoRootDraft} onDraftChange=${setVideoRootDraft} + busy=${videoRootBusy} msg=${videoRootMsg} onSave=${saveVideoRoot} + noneKey="settings_node.video_root_none" saveKey="settings_node.video_root_save" /> + `} + ${activeApps.includes('music') && html` + <${RootFolderRow} icon="music" + titleKey="settings_node.audio_root_title" hintKey="settings_node.audio_root_hint" + folders=${rootFolderOptions} value=${audioRoot} + draft=${audioRootDraft} onDraftChange=${setAudioRootDraft} + busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot} + noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" /> + `} + ${/* Roots management (Electron-only, when node is local) — folded into + the same Directories section as the two root pickers above. */ nodeDetected && nodeRoots.length > 0 && html` -
-

${t('settings_node.roots')}

+
+
+ <${Icon} name="server" /> +

${t('settings_node.roots')}

+
${nodeMsg && html`

${nodeMsg}

`}
${nodeRoots.map(r => html` @@ -912,38 +1037,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`} + + `} ${/* Operator only, and only with a live connection: the node is what holds and enforces this, so there is nothing to show or change without one. */ isNodeAdmin && connected && html` -
-

${t('members.uploads_title')}

+ <${CollapsibleSection} titleKey="members.uploads_title">
- - ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} - - + <${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy} + onChange=${() => setUploads(!memberUpload)} + label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />

${t('members.uploads_hint')}

${uploadMsg && html`

${uploadMsg}

`} -
+ `} ${/* Upload toggle via loopback when MNP not connected */ nodeDetected && !connected && html` -
-

${t('members.uploads_title')}

+ <${CollapsibleSection} titleKey="members.uploads_title">
- - ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} - - + }} + label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />

${t('members.uploads_hint')}

-
+ `} ${/* Delete/leave — node detach first (reversible), then hub delete - (irreversible). */ html` -
-

- ${isOwner ? t('group.delete_group') : t('group.leave')} -

+ (irreversible). Closed by default: a danger-zone action is one + click away either way, but not the first thing seen on open. */ + html` + <${CollapsibleSection} defaultOpen=${false} + title=${isOwner ? t('group.delete_group') : t('group.leave')}>
${isOwner ? t('members.danger_delete_hint') @@ -1004,7 +1118,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, }}>${t('group.leave')} `}
-
+ `} ${connected && html` @@ -1045,10 +1159,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`} -
-

- ${t('group.tab_members')} (${members.length}) -

+ <${CollapsibleSection} title=${`${t('group.tab_members')} (${members.length})`}> @@ -1085,7 +1196,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${isAdmin && members.length > 1 && html`

${t('members.remove_hint')}

`} - + `; } -- cgit v1.2.3