diff options
Diffstat (limited to 'packages')
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) { |