aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js287
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css105
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js20
15 files changed, 441 insertions, 92 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 93b0466..727bfc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -87,6 +87,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Which folder is the Videos app's entry point for this group — ''
// (the default) means the whole group index. Set from Files, per-group.
const [videoRoot, setVideoRoot] = useState('');
+ // Same shape — the Music app's own entry point.
+ const [audioRoot, setAudioRoot] = useState('');
// MusicBrainz on/off (per-group) + whether a contact string is configured
// (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
@@ -238,6 +240,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
language: ack.tmdb_language || '',
});
setVideoRoot(ack.video_root || '');
+ setAudioRoot(ack.audio_root || '');
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
contactConfigured: !!ack.musicbrainz_contact_configured,
@@ -255,6 +258,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }));
transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }));
transport.onVideoRoot = (path) => setVideoRoot(path);
+ transport.onAudioRoot = (path) => setAudioRoot(path);
transport.onMusicbrainzConfig = (cfg) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg }));
transport.onMusicbrainzEnabled = (enabled) =>
@@ -467,6 +471,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
videoRoot, onVideoRoot: (path) => setVideoRoot(path),
+ audioRoot, onAudioRoot: (path) => setAudioRoot(path),
tmdbConfig,
musicbrainzConfig, onPlayQueue,
};
@@ -591,6 +596,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
entries=${entries} nodeDirs=${nodeDirs}
videoRoot=${videoRoot}
onVideoRoot=${(path) => setVideoRoot(path)}
+ audioRoot=${audioRoot}
+ onAudioRoot=${(path) => setAudioRoot(path)}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
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`
+ <div class="settings-section">
+ <button type="button" class="settings-collapsible-header"
+ onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
+ <h3 class="settings-heading">${title != null ? title : t(titleKey)}</h3>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </button>
+ ${open && html`<div class="settings-collapsible-body">${children}</div>`}
+ </div>
+ `;
+}
+
+/**
+ * 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 <input type="checkbox">
+ * 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`
+ <label class="toggle-switch ${disabled ? 'toggle-switch-disabled' : ''}">
+ <input type="checkbox" checked=${checked} disabled=${disabled}
+ onChange=${(e) => onChange(e.target.checked)} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ ${label != null && html`<span class="toggle-switch-label">${label}</span>`}
+ </label>
+ `;
+}
+
+/**
+ * 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 <select> over every folder the group's
+ * index already knows about, a Save button that only enables once the
+ * draft actually differs, and a confirm prompt only when replacing an
+ * *already-set* root (setting one for the first time has nothing to lose).
+ */
+function RootFolderRow({
+ icon, titleKey, hintKey, folders, value, draft, onDraftChange,
+ busy, msg, onSave, noneKey, saveKey,
+}) {
+ return html`
+ <div class="settings-root-row">
+ <div class="settings-root-row-title">
+ <${Icon} name=${icon} />
+ <h4>${t(titleKey)}</h4>
+ </div>
+ <p class="settings-hint">${t(hintKey)}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <select value=${draft} disabled=${busy} onChange=${e => onDraftChange(e.target.value)}>
+ <option value="">${t(noneKey)}</option>
+ ${folders.map(p => html`
+ <option key=${p} value=${p}>
+ ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
+ </option>
+ `)}
+ </select>
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy || draft === (value || '')} onClick=${onSave}>
+ ${busy ? t('settings_node.scan_saving') : t(saveKey)}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
// ── 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
- // <select> rather than a live folder browser: choosing the Videos root is
- // a rare, one-off decision, not something worth a whole navigable tree for.
- const videoRootFolders = useMemo(() => {
+ // <select> 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <${CollapsibleSection} titleKey="members.apps_title">
<p class="settings-hint">${t('members.apps_hint')}</p>
<ul class="apps-toggle-list">
${APPS.map(a => html`
@@ -671,15 +789,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`)}
</ul>
${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.scan_title')}</h3>
+ <${CollapsibleSection} titleKey="settings_node.scan_title" defaultOpen=${false}>
<p class="settings-hint">${t('settings_node.scan_hint')}</p>
<div class="settings-row">
<label class="settings-label">
@@ -702,7 +819,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${scanBusy ? t('settings_node.scan_saving') : t('settings_node.scan_save')}
</button>
${scanMsg && html`<p class="settings-hint">${scanMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3>
+ <${CollapsibleSection} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')}
+ <span class="settings-meta-badge ${tmdbEnabled ? 'on' : ''}">
+ ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
+ </span>
+ </span>
+ `}>
<p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
<div class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
- onChange=${(e) => saveTmdbEnabled(e.target.checked)} />
- ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
- </label>
+ <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
+ onChange=${(v) => saveTmdbEnabled(v)}
+ label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
</div>
<div class="settings-row">
<label class="settings-label">
@@ -754,7 +875,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
</button>
${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.musicbrainz_title')}</h3>
+ <${CollapsibleSection} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')}
+ <span class="settings-meta-badge ${mbEnabled ? 'on' : ''}">
+ ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
+ </span>
+ </span>
+ `}>
<p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
<div class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${mbEnabled} disabled=${mbEnabledBusy}
- onChange=${(e) => saveMusicbrainzEnabled(e.target.checked)} />
- ${' '}${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
- </label>
+ <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
+ onChange=${(v) => saveMusicbrainzEnabled(v)}
+ label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
</div>
<div class="settings-row">
<label class="settings-label">
@@ -791,7 +916,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${mbBusy ? t('settings_node.scan_saving') : t('settings_node.musicbrainz_save')}
</button>
${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3>
- <p class="settings-hint">${t('settings_node.video_root_hint')}</p>
- <div class="settings-row">
- <label class="settings-label">
- <select value=${videoRootDraft} disabled=${videoRootBusy}
- onChange=${e => setVideoRootDraft(e.target.value)}>
- <option value="">${t('settings_node.video_root_none')}</option>
- ${videoRootFolders.map(p => html`
- <option key=${p} value=${p}>
- ${'  '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')}
- onClick=${saveVideoRoot}>
- ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')}
- </button>
- ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`}
- </div>
- `}
+ isNodeAdmin && connected
+ && ((nodeDetected && nodeRoots.length > 0)
+ || activeApps.includes('video') || activeApps.includes('music')) && html`
+ <${CollapsibleSection} titleKey="settings_node.directories_title">
+ <p class="settings-hint">${t('settings_node.directories_hint')}</p>
- ${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ <div class="settings-root-row">
+ <div class="settings-root-row-title">
+ <${Icon} name="server" />
+ <h4>${t('settings_node.roots')}</h4>
+ </div>
${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
<div class="node-roots">
${nodeRoots.map(r => html`
@@ -912,38 +1037,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
</div>
`}
+ </${CollapsibleSection}>
+ `}
${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <${CollapsibleSection} titleKey="members.uploads_title">
<div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${uploadBusy}
- onClick=${() => setUploads(!memberUpload)}>
- ${uploadBusy ? '...'
- : (memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable'))}
- </button>
+ <${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy}
+ onChange=${() => setUploads(!memberUpload)}
+ label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
</div>
<p class="settings-hint">${t('members.uploads_hint')}</p>
${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* Upload toggle via loopback when MNP not connected */
nodeDetected && !connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <${CollapsibleSection} titleKey="members.uploads_title">
<div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${nodeBusy}
- onClick=${async () => {
+ <${ToggleSwitch} checked=${memberUpload} disabled=${nodeBusy}
+ onChange=${async () => {
setNodeBusy(true); setNodeMsg('');
try {
const newVal = !memberUpload;
@@ -953,21 +1069,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
if (onMemberUpload) onMemberUpload(newVal);
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
finally { setNodeBusy(false); }
- }}>
- ${memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable')}
- </button>
+ }}
+ label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
</div>
<p class="settings-hint">${t('members.uploads_hint')}</p>
- </div>
+ </${CollapsibleSection}>
`}
${/* Delete/leave — node detach first (reversible), then hub delete
- (irreversible). */ html`
- <div class="settings-section">
- <h3 class="settings-heading">
- ${isOwner ? t('group.delete_group') : t('group.leave')}
- </h3>
+ (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')}>
<div class="settings-row">
<span class="settings-label">
${isOwner ? t('members.danger_delete_hint')
@@ -1004,7 +1118,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}}>${t('group.leave')}</button>
`}
</div>
- </div>
+ </${CollapsibleSection}>
`}
${connected && html`
@@ -1045,10 +1159,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- <div class="settings-section">
- <h3 class="settings-heading">
- ${t('group.tab_members')} (${members.length})
- </h3>
+ <${CollapsibleSection} title=${`${t('group.tab_members')} (${members.length})`}>
<table class="admin-table">
<thead>
<tr>
@@ -1085,7 +1196,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${isAdmin && members.length > 1 && html`
<p class="settings-hint">${t('members.remove_hint')}</p>
`}
- </div>
+ </${CollapsibleSection}>
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index b05c8b1..83b5c1f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -162,6 +162,7 @@ export default {
'video.mode_flat': 'Flache Liste',
'video.empty': 'Keine Videos gefunden.',
'video.no_root_configured': 'Für diese Gruppe ist noch kein Videos-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
+ 'music.no_root_configured': 'Für diese Gruppe ist noch kein Musik-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
'video.director': 'Regie',
'video.specials': 'Specials',
'video.season_n': 'Staffel {n}',
@@ -645,6 +646,14 @@ export default {
'settings_node.video_root_save': 'Speichern',
'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?',
+ 'settings_node.audio_root_title': 'Musik-Stammordner',
+ 'settings_node.audio_root_hint': 'Welcher Ordner (oder Unterordner) als Einstiegspunkt der Musik-App für diese Gruppe dient. In Musik wird nichts angezeigt, und es werden keine Tag-/Cover-Daten gelesen, bis einer ausgewählt wurde.',
+ 'settings_node.audio_root_none': '— keiner ausgewählt —',
+ 'settings_node.audio_root_save': 'Speichern',
+ 'settings_node.audio_root_change_confirm': 'Das Ändern des Musik-Stammordners ersetzt, was jedes Mitglied im Musik-Tab sieht. Fortfahren?',
+ 'settings_node.directories_title': 'Verzeichnisse',
+ 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos- und Musik-Apps als eigenen Einstiegspunkt nutzen.',
+
// Create-group wizard
'wizard.title': 'Gruppe erstellen',
'wizard.detecting': 'Lokaler Node wird erkannt…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index db7d048..1101eb2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -160,6 +160,7 @@ export default {
'video.mode_flat': 'Flat list',
'video.empty': 'No videos found.',
'video.no_root_configured': 'No Videos root folder is set for this group yet — an operator can choose one in Settings.',
+ 'music.no_root_configured': 'No Music root folder is set for this group yet — an operator can choose one in Settings.',
'video.director': 'Director',
'video.specials': 'Specials',
'video.season_n': 'Season {n}',
@@ -470,6 +471,14 @@ export default {
'settings_node.video_root_save': 'Save',
'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?',
+ 'settings_node.audio_root_title': 'Music root folder',
+ 'settings_node.audio_root_hint': 'Which folder (or subfolder) the Music app treats as its entry point for this group. Nothing shows in Music, and no tag/cover reading runs, until one is chosen.',
+ 'settings_node.audio_root_none': '— none chosen —',
+ 'settings_node.audio_root_save': 'Save',
+ 'settings_node.audio_root_change_confirm': 'Changing the Music root replaces what every member sees in the Music tab. Continue?',
+ 'settings_node.directories_title': 'Directories',
+ 'settings_node.directories_hint': 'Shared folders, and which of them the Videos and Music apps use as their own entry point.',
+
// Members
'members.col_role': 'Role',
'members.group_role': 'Group role',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 8f29908..d9fd76f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -160,6 +160,7 @@ export default {
'video.mode_flat': 'Lista plana',
'video.empty': 'No se encontraron vídeos.',
'video.no_root_configured': 'Aún no se ha definido una carpeta raíz de Vídeos para este grupo — un operador puede elegir una en Configuración.',
+ 'music.no_root_configured': 'Aún no se ha definido una carpeta raíz de Música para este grupo — un operador puede elegir una en Configuración.',
'video.director': 'Director',
'video.specials': 'Especiales',
'video.season_n': 'Temporada {n}',
@@ -640,6 +641,14 @@ export default {
'settings_node.video_root_save': 'Guardar',
'settings_node.video_root_change_confirm': 'Cambiar la raíz de Vídeos reemplaza lo que ve cada miembro en la pestaña Vídeos. ¿Continuar?',
+ 'settings_node.audio_root_title': 'Carpeta raíz de Música',
+ 'settings_node.audio_root_hint': 'Qué carpeta (o subcarpeta) trata la app Música como su punto de entrada para este grupo. No se muestra nada en Música, ni se leen etiquetas o carátulas, hasta que se elija una.',
+ 'settings_node.audio_root_none': '— ninguna elegida —',
+ 'settings_node.audio_root_save': 'Guardar',
+ 'settings_node.audio_root_change_confirm': 'Cambiar la raíz de Música reemplaza lo que ve cada miembro en la pestaña Música. ¿Continuar?',
+ 'settings_node.directories_title': 'Directorios',
+ 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos y Música como su propio punto de entrada.',
+
// Create group wizard
'wizard.title': 'Crear grupo',
'wizard.detecting': 'Detectando node local…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 5d83517..7e0b13f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -161,6 +161,7 @@ export default {
'video.mode_flat': 'Liste à plat',
'video.empty': 'Aucune vidéo trouvée.',
'video.no_root_configured': "Aucun dossier racine des Vidéos n'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.",
+ 'music.no_root_configured': 'Aucun dossier racine de Musique n\'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.',
'video.director': 'Réalisateur',
'video.specials': 'Bonus',
'video.season_n': 'Saison {n}',
@@ -656,6 +657,14 @@ export default {
'settings_node.video_root_save': 'Enregistrer',
'settings_node.video_root_change_confirm': "Changer la racine des Vidéos remplace ce que chaque membre voit dans l'onglet Vidéos. Continuer ?",
+ 'settings_node.audio_root_title': 'Dossier racine de la Musique',
+ 'settings_node.audio_root_hint': 'Quel dossier (ou sous-dossier) sert de point d\'entrée à l\'application Musique pour ce groupe. Rien ne s\'affiche dans Musique, et aucune lecture de tags/pochettes n\'est effectuée, tant qu\'aucun n\'est choisi.',
+ 'settings_node.audio_root_none': '— aucun choisi —',
+ 'settings_node.audio_root_save': 'Enregistrer',
+ 'settings_node.audio_root_change_confirm': 'Changer la racine de la Musique remplace ce que chaque membre voit dans l\'onglet Musique. Continuer ?',
+ 'settings_node.directories_title': 'Répertoires',
+ 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos et Musique utilisent comme leur propre point d\'entrée.',
+
// Create group wizard
'wizard.title': 'Créer un groupe',
'wizard.detecting': 'Détection du node local…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index fb88cbe..7d2fada 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -161,6 +161,7 @@ export default {
'video.mode_flat': 'Elenco semplice',
'video.empty': 'Nessun video trovato.',
'video.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Video — un operatore può sceglierne una nelle Impostazioni.',
+ 'music.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Musica — un operatore può sceglierne una nelle Impostazioni.',
'video.director': 'Regista',
'video.specials': 'Speciali',
'video.season_n': 'Stagione {n}',
@@ -654,6 +655,14 @@ export default {
'settings_node.video_root_save': 'Salva',
'settings_node.video_root_change_confirm': 'Cambiare la radice di Video sostituisce ciò che ogni membro vede nella scheda Video. Continuare?',
+ 'settings_node.audio_root_title': 'Cartella radice di Musica',
+ 'settings_node.audio_root_hint': 'Quale cartella (o sottocartella) l\'app Musica considera come punto di ingresso per questo gruppo. In Musica non viene mostrato nulla, e non viene letto alcun tag/copertina, finché non ne viene scelta una.',
+ 'settings_node.audio_root_none': '— nessuna scelta —',
+ 'settings_node.audio_root_save': 'Salva',
+ 'settings_node.audio_root_change_confirm': 'Cambiare la radice di Musica sostituisce ciò che ogni membro vede nella scheda Musica. Continuare?',
+ 'settings_node.directories_title': 'Directory',
+ 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video e Musica usano come proprio punto di ingresso.',
+
// Create-group wizard
'wizard.title': 'Crea gruppo',
'wizard.detecting': 'Rilevamento del node locale…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 84e9eb5..38fb19b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -158,6 +158,7 @@ export default {
'video.mode_flat': 'フラット表示',
'video.empty': '動画が見つかりません。',
'video.no_root_configured': 'このグループにはまだ動画のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
+ 'music.no_root_configured': 'このグループにはまだ音楽のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
'video.director': '監督',
'video.specials': '特別編',
'video.season_n': 'シーズン{n}',
@@ -638,6 +639,14 @@ export default {
'settings_node.video_root_save': '保存',
'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?',
+ 'settings_node.audio_root_title': '音楽のルートフォルダ',
+ 'settings_node.audio_root_hint': 'このグループで音楽アプリの起点とするフォルダ(またはサブフォルダ)です。選択されるまで、音楽には何も表示されず、タグ・カバー情報の読み込みも行われません。',
+ 'settings_node.audio_root_none': '— 未選択 —',
+ 'settings_node.audio_root_save': '保存',
+ 'settings_node.audio_root_change_confirm': '音楽のルートフォルダを変更すると、全メンバーの音楽タブの表示内容が変わります。続行しますか?',
+ 'settings_node.directories_title': 'ディレクトリ',
+ 'settings_node.directories_hint': '共有フォルダと、動画アプリ・音楽アプリがそれぞれの起点として使用するフォルダです。',
+
// Wizard
'wizard.title': 'グループを作成',
'wizard.detecting': 'ローカル node を検出中…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 06097e6..fdfcb89 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -162,6 +162,7 @@ export default {
'video.mode_flat': 'Platte lijst',
'video.empty': "Geen video's gevonden.",
'video.no_root_configured': "Er is nog geen hoofdmap voor Video's ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.",
+ 'music.no_root_configured': 'Er is nog geen hoofdmap voor Muziek ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.',
'video.director': 'Regisseur',
'video.specials': "Extra's",
'video.season_n': 'Seizoen {n}',
@@ -656,6 +657,14 @@ export default {
'settings_node.video_root_save': 'Opslaan',
'settings_node.video_root_change_confirm': "Het wijzigen van de hoofdmap voor Video's vervangt wat elk lid ziet in het tabblad Video's. Doorgaan?",
+ 'settings_node.audio_root_title': 'Hoofdmap voor Muziek',
+ 'settings_node.audio_root_hint': 'Welke map (of submap) de Muziek-app als startpunt gebruikt voor deze groep. Er wordt niets getoond in Muziek, en er worden geen tags/covers gelezen, totdat er een gekozen is.',
+ 'settings_node.audio_root_none': '— geen gekozen —',
+ 'settings_node.audio_root_save': 'Opslaan',
+ 'settings_node.audio_root_change_confirm': 'Het wijzigen van de hoofdmap voor Muziek vervangt wat elk lid ziet in het tabblad Muziek. Doorgaan?',
+ 'settings_node.directories_title': 'Mappen',
+ 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s- en Muziek-apps als eigen startpunt gebruiken.',
+
// Create group wizard
'wizard.title': 'Groep aanmaken',
'wizard.detecting': 'Lokale node wordt gedetecteerd…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index e24fd1e..8e7f636 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -167,6 +167,7 @@ export default {
'video.mode_flat': 'Lista płaska',
'video.empty': 'Nie znaleziono żadnych filmów.',
'video.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Wideo — operator może go wybrać w Ustawieniach.',
+ 'music.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Muzyki — operator może go wybrać w Ustawieniach.',
'video.director': 'Reżyser',
'video.specials': 'Dodatki',
'video.season_n': 'Sezon {n}',
@@ -681,6 +682,14 @@ export default {
'settings_node.video_root_save': 'Zapisz',
'settings_node.video_root_change_confirm': 'Zmiana katalogu głównego Wideo zastępuje to, co widzi każdy członek w karcie Wideo. Kontynuować?',
+ 'settings_node.audio_root_title': 'Katalog główny Muzyki',
+ 'settings_node.audio_root_hint': 'Który katalog (lub podkatalog) aplikacja Muzyka traktuje jako punkt wejścia dla tej grupy. W Muzyce nic się nie wyświetla i nie są odczytywane żadne tagi/okładki, dopóki nie zostanie wybrany.',
+ 'settings_node.audio_root_none': '— nie wybrano —',
+ 'settings_node.audio_root_save': 'Zapisz',
+ 'settings_node.audio_root_change_confirm': 'Zmiana katalogu głównego Muzyki zastępuje to, co widzi każdy członek w karcie Muzyka. Kontynuować?',
+ 'settings_node.directories_title': 'Katalogi',
+ 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo i Muzyka traktują jako własny punkt wejścia.',
+
// Create-group wizard
'wizard.title': 'Utwórz grupę',
'wizard.detecting': 'Wykrywanie lokalnego node…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 1a3069a..97786f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -162,6 +162,7 @@ export default {
'video.mode_flat': 'Lista simples',
'video.empty': 'Nenhum vídeo encontrado.',
'video.no_root_configured': 'Ainda não há uma pasta raiz de Vídeos definida para este grupo — um operador pode escolher uma em Configurações.',
+ 'music.no_root_configured': 'Ainda não há uma pasta raiz de Música definida para este grupo — um operador pode escolher uma em Configurações.',
'video.director': 'Diretor',
'video.specials': 'Especiais',
'video.season_n': 'Temporada {n}',
@@ -641,6 +642,14 @@ export default {
'settings_node.video_root_save': 'Salvar',
'settings_node.video_root_change_confirm': 'Alterar a raiz de Vídeos substitui o que cada membro vê na aba Vídeos. Continuar?',
+ 'settings_node.audio_root_title': 'Pasta raiz de Música',
+ 'settings_node.audio_root_hint': 'Qual pasta (ou subpasta) o app Música trata como ponto de entrada para este grupo. Nada é exibido em Música, e nenhuma leitura de tags/capas é feita, até que uma seja escolhida.',
+ 'settings_node.audio_root_none': '— nenhuma escolhida —',
+ 'settings_node.audio_root_save': 'Salvar',
+ 'settings_node.audio_root_change_confirm': 'Alterar a raiz de Música substitui o que cada membro vê na aba Música. Continuar?',
+ 'settings_node.directories_title': 'Diretórios',
+ 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos e Música tratam como seu próprio ponto de entrada.',
+
// Create group wizard
'wizard.title': 'Criar grupo',
'wizard.detecting': 'Detectando node local…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 236747e..a26062d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -155,6 +155,7 @@ export default {
'video.mode_flat': '平铺列表',
'video.empty': '未找到视频。',
'video.no_root_configured': '此群组尚未设置视频根目录 — 操作员可以在设置中选择一个。',
+ 'music.no_root_configured': '此群组尚未设置音乐根目录 — 操作员可以在设置中选择一个。',
'video.director': '导演',
'video.specials': '特别篇',
'video.season_n': '第 {n} 季',
@@ -624,6 +625,14 @@ export default {
'settings_node.video_root_save': '保存',
'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?',
+ 'settings_node.audio_root_title': '音乐根目录',
+ 'settings_node.audio_root_hint': '该文件夹(或子文件夹)将作为此群组“音乐”应用的入口。在选择之前,“音乐”中不会显示任何内容,也不会读取任何标签/封面信息。',
+ 'settings_node.audio_root_none': '— 未选择 —',
+ 'settings_node.audio_root_save': '保存',
+ 'settings_node.audio_root_change_confirm': '更改音乐根目录会替换每位成员在“音乐”标签页中看到的内容。是否继续?',
+ 'settings_node.directories_title': '目录',
+ 'settings_node.directories_hint': '共享文件夹,以及“视频”和“音乐”应用各自使用哪个作为入口。',
+
// Create group wizard
'wizard.title': '创建群组',
'wizard.detecting': '正在检测本地 node…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index cb14fb4..298e259 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -46,12 +46,24 @@ function foldKey(s) {
.replace(/\s*&\s*/g, ' and ').replace(/\s+/g, ' ').trim();
}
-function groupMusicEntries(entries) {
+// Same shape as video-app.js's underVideoRoot: an unset root means "show
+// nothing" (docs/musicbay.md's amended §2.1 — the node itself runs no
+// tag/cover enrichment for this group before a root is chosen either,
+// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
+// falling back to that would just show files nothing has enriched.
+function underAudioRoot(entry, audioRoot) {
+ if (!audioRoot) return false;
+ const p = entry.path || '';
+ return p === audioRoot || p.startsWith(audioRoot + '/');
+}
+
+function groupMusicEntries(entries, audioRoot) {
const tracks = []; // no artist at all, even after the folder fallback -- rare, but real
const byArtistKey = new Map(); // foldKey(artist) -> { artist, albumsByKey: Map, loose: [] }
for (const e of entries) {
if (e.type !== 'audio') continue;
+ if (!underAudioRoot(e, audioRoot)) continue;
const artistRaw = (e.artist || '').trim();
if (!artistRaw) { tracks.push(e); continue; }
const artistKey = foldKey(artistRaw);
@@ -381,7 +393,7 @@ function FlatList({ tracks, artists, onPlayQueue }) {
// -- shell --------------------------------------------------------------------
function MusicApp({
- groupId, transportRef, gekRef, status, entries, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -392,7 +404,8 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
- const { tracks, artists, albums } = useMemo(() => groupMusicEntries(entries), [entries]);
+ const { tracks, artists, albums } = useMemo(
+ () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
@@ -418,7 +431,10 @@ function MusicApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && html`
+ ${status === 'connected' && !audioRoot && html`
+ <p class="page-message">${t('music.no_root_configured')}</p>
+ `}
+ ${status === 'connected' && audioRoot && html`
<div class="video-toolbar">
<button class="tb-btn ${mode === 'grid' ? 'active' : ''}"
onClick=${() => setModeAndSave('grid')}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 38c38c8..b332f6f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1020,6 +1020,111 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
color: var(--text-secondary);
}
+/* Collapsible settings sections (2026-08-24) — every section but a form
+ someone might be mid-typing into (invite, pair-operator, approve-device,
+ left as plain .settings-section blocks). Reuses .video-flat-chevron for
+ the rotate-on-open animation rather than declaring a near-duplicate. */
+.settings-collapsible-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+}
+.settings-collapsible-header .settings-heading { margin-bottom: 0; }
+.settings-collapsible-body { margin-top: 12px; }
+
+.settings-meta-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-transform: none;
+ letter-spacing: normal;
+ font-weight: 600;
+ font-size: 1.1em;
+ color: var(--text);
+}
+.settings-meta-title .icon { width: 16px; height: 16px; }
+.settings-meta-badge {
+ font-size: 0.75em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--bg-raised);
+ color: var(--text-dim);
+}
+.settings-meta-badge.on { background: var(--accent); color: #fff; }
+
+/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
+ wherever the setting is a straight binary. */
+.toggle-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ font-size: 0.9em;
+ color: var(--text);
+}
+.toggle-switch input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+.toggle-switch-track {
+ position: relative;
+ width: 38px;
+ height: 21px;
+ border-radius: 11px;
+ background: var(--border);
+ flex-shrink: 0;
+ transition: background 0.15s;
+}
+.toggle-switch-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 17px;
+ height: 17px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+ transition: transform 0.15s;
+}
+.toggle-switch input:checked + .toggle-switch-track { background: var(--accent); }
+.toggle-switch input:checked + .toggle-switch-track .toggle-switch-thumb {
+ transform: translateX(17px);
+}
+.toggle-switch input:focus-visible + .toggle-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
+.toggle-switch-disabled { opacity: 0.5; cursor: default; }
+
+/* A sub-block within a merged settings section (Directories: shared roots,
+ Videos root, Music root, all under one CollapsibleSection). */
+.settings-root-row { padding: 10px 0; }
+.settings-root-row + .settings-root-row { border-top: 1px solid var(--border); }
+.settings-root-row-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.settings-root-row-title .icon { width: 15px; height: 15px; color: var(--text-dim); }
+.settings-root-row-title h4 {
+ font-size: 0.85em;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+}
+
.settings-select {
padding: 6px 10px;
border: 1px solid var(--border);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 4f8e3b5..817db7a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -109,6 +109,7 @@ class MeshBayTransport {
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onVideoRoot(fn) { this._onVideoRoot = fn; }
+ set onAudioRoot(fn) { this._onAudioRoot = fn; }
set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; }
set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
@@ -606,6 +607,19 @@ class MeshBayTransport {
}
/**
+ * Same shape as setVideoRoot above — the Music app's own entry point.
+ */
+ async setAudioRoot(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({ type: 'audio_root', v: '0.10', path: clean });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'audio_root', clean, signFn);
+ }
+ return msg;
+ }
+
+ /**
* MusicBrainz metadata for one track's path (Music app, docs/musicbay.md
* §4.3) — same shape as fetchMediaMeta, minus a season/episode concept:
* album-level (release), resolved from the track's own artist/album
@@ -1540,6 +1554,12 @@ class MeshBayTransport {
this._onVideoRoot(msg.path || '');
}
+ // Same shape: the operator changed which folder is the Music app's
+ // entry point for this group.
+ if (msg.type === 'audio_root_ack' && this._onAudioRoot) {
+ this._onAudioRoot(msg.path || '');
+ }
+
// Node-wide, like tmdb_config_ack above — no token equivalent to hide,
// only whether a contact string is configured (docs/musicbay.md §3.2).
if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) {