Files
ytplayer/frontend/settings-sections.js
2026-10-08 01:36:47 +08:00

128 lines
9.7 KiB
JavaScript

/* Settings navigation and extension registry. All controls stay mounted. */
(function (root) {
'use strict';
const clusters = ['Listening', 'Library & storage', 'Live & worship', 'Account & sync', 'Advanced & about'];
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function search(entries, query) {
const words = normalize(query).split(' ').filter(Boolean);
return entries.filter(entry => words.every(word => normalize(`${entry.title} ${entry.description || ''}`).includes(word)));
}
function createRegistry() {
const entries = new Map();
return {
register(section) {
if (!section || !/^[a-z][a-z0-9-]*$/.test(section.id) || !section.title || typeof section.render !== 'function') throw new TypeError('A settings section needs an id, title and render function.');
entries.set(section.id, { cluster: 'Advanced & about', summary: () => '', ...section });
return () => { if (entries.get(section.id)?.render === section.render) entries.delete(section.id); };
},
list() { return [...entries.values()]; },
get(id) { return entries.get(id); },
};
}
const registry = createRegistry();
let dispose = () => {};
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
function mount(host, context = {}) {
dispose();
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [], searchLoads = new Set();
let mounted = true;
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
let active = registry.get(remembered) ? remembered : sections[0]?.id;
host.classList.add('settings-browser');
const toolbar = document.createElement('div'); toolbar.className = 'settings-search';
const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'Search settings'; input.setAttribute('aria-label', 'Search settings and controls'); toolbar.append(input);
const grid = document.createElement('div'); grid.className = 'settings-grid';
const navigation = document.createElement('nav'); navigation.className = 'settings-overview'; navigation.setAttribute('aria-label', 'Settings sections');
const detail = document.createElement('div'); detail.className = 'settings-detail';
const back = document.createElement('button'); back.type = 'button'; back.className = 'settings-back'; back.textContent = '← All settings'; detail.append(back);
const results = document.createElement('div'); results.className = 'settings-results'; results.hidden = true;
const empty = document.createElement('p'); empty.className = 'settings-empty'; empty.textContent = 'No settings match your search.'; empty.hidden = true;
for (const cluster of [...clusters, ...new Set(sections.map(s => s.cluster).filter(c => !clusters.includes(c)))]) {
const group = sections.filter(s => s.cluster === cluster); if (!group.length) continue;
const heading = document.createElement('h3'); heading.textContent = cluster; navigation.append(heading);
for (const section of group) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row'; button.dataset.section = section.id;
button.innerHTML = icon(section.icon);
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
if (!section.lazyGroup) {
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
}
button.addEventListener('click', () => open(section.id));
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
}
}
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
function show(id, inDetail, focusId) {
if (registry.get(id)) active = id;
const section = sections.find(section => section.id === active);
const panel = panels.get(active);
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
panel.dataset.rendered = '1';
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
}
host.dataset.detail = inDetail ? '1' : '0';
for (const [key, panel] of panels) panel.hidden = key !== active;
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
if (inDetail) {
try { win.sessionStorage.setItem('ytpSettingsSection', active); } catch {}
const target = focusId ? document.getElementById(focusId) : panels.get(active)?.querySelector('input,select,button,textarea,summary');
if (target) { const fold = target.closest('details'); if (fold) fold.open = true; target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center', behavior: 'instant' }); }
}
}
function open(id, focusId) {
if (!panels.has(id)) return;
input.value = ''; filter();
win.history.pushState({ ...win.history.state, ytpSettingsSection: id }, '', win.location.href);
show(id, true, focusId);
}
function filter() {
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
if (!query) { empty.hidden = true; return; }
// Searching is explicit feature intent. Mount optional controls so their
// existing labels/help remain searchable without parsing them on opening Settings.
for (const section of sections) {
const panel=panels.get(section.id);
if(!section.lazyGroup || panel.dataset.rendered || searchLoads.has(section.id))continue;
searchLoads.add(section.id);
root.Lazy.load(section.lazyGroup).then(()=>{
if(!mounted || !host.isConnected || panel.dataset.rendered)return;
panel.dataset.rendered='1';
const cleanup=registry.get(section.id).render(panel,context);if(typeof cleanup==='function')cleanups.push(cleanup);
filter();
}).catch(()=>searchLoads.delete(section.id));
}
const entries = [];
for (const section of sections) {
entries.push({ title: section.title, description: section.description || '', section: section.id });
for (const el of panels.get(section.id).querySelectorAll('.set-row, .set-actions button, [data-settings-search]')) {
const control = el.matches('button,input,select') ? el : el.querySelector('input,select,button,textarea,summary');
if (control && !control.id) control.id = `settings-control-${section.id}-${entries.length}`;
const title = (el.querySelector('span')?.childNodes[0]?.textContent || el.textContent).trim();
entries.push({ title, description: `${section.title} ${el.textContent}`, section: section.id, control: control?.id });
}
}
const matches = search(entries, query); empty.hidden = matches.length > 0;
for (const item of matches.slice(0, 40)) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row';
const text = document.createElement('span'); text.textContent = item.title; const subtitle = document.createElement('small'); subtitle.textContent = registry.get(item.section).title; text.append(subtitle); button.append(text);
button.addEventListener('click', () => open(item.section, item.control)); results.append(button);
}
}
back.addEventListener('click', () => { if (win.history.state?.ytpSettingsSection) win.history.back(); else show(active, false); });
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
const observer = new win.MutationObserver(()=>{summaries();if(input.value.trim())filter();}); observer.observe(detail, { childList: true, subtree: true, characterData: true });
const state = win.history.state?.ytpSettingsSection;
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
show(state || active, !!state); summaries();
dispose = () => { mounted=false;observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
return { open, refresh: summaries, dispose };
}
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.SettingsSections = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);