Add voice search and large-control Car mode

This commit is contained in:
Jonathan Sykes
2026-10-03 15:10:30 +08:00
parent 172e8231f0
commit 0b3e82a768
7 changed files with 50 additions and 0 deletions

View File

@@ -12472,6 +12472,7 @@ async function boot() {
else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() });
} });
applyAppearance();
CarMode.configure({ input: els.searchInput, search: () => els.searchForm.requestSubmit(), toast, state: () => ({ title: current?.meta?.title || '', paused: Player.master?.paused !== false }), action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); else if (action === 'prev') playPrev(); else if (action === 'back') Player.seek(Math.max(0, (Player.master.currentTime || 0) - 15)); } });
updateProfileStatus();
updateLoopRepeatButtons();
syncMuteIcon();

7
frontend/car-mode.css Normal file
View File

@@ -0,0 +1,7 @@
.car-mode { position: fixed; inset: 0; z-index: 20000; background: #070809; color: #fff; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); overflow: auto; display: flex; flex-direction: column; gap: 20px; }
.car-mode h1 { font: 650 clamp(24px, 4vw, 42px)/1.25 system-ui; margin: auto 0; overflow-wrap: anywhere; text-align: center; }
.car-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; width: min(100%, 800px); margin: auto; }
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }

29
frontend/car-mode.js Normal file
View File

@@ -0,0 +1,29 @@
/* Voice search and a deliberately sparse, high-contrast transport. */
(function(root) {
'use strict';
function transcript(event) { let text = ''; for (let i = event.resultIndex || 0; i < (event.results?.length || 0); i++) if (event.results[i].isFinal) text += `${event.results[i][0]?.transcript || ''} `; return text.trim(); }
let adapter, overlay, timer, savedFocus, previous = [];
function close() { if (!overlay) return; clearInterval(timer); overlay.remove(); overlay = null; for (const [node, inert] of previous) node.inert = inert; previous = []; savedFocus?.focus?.(); }
function open() {
if (overlay) return;
const doc = root.document; savedFocus = doc.activeElement; previous = [...doc.body.children].map(node => [node, node.inert]); previous.forEach(([node]) => { node.inert = true; });
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
}
function configure(options) {
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
if (Recognition) {
const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; adapter.input.after(b);
let recognition, listening = false;
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
}
const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; doc.getElementById('searchForm').after(car); car.onclick = open;
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
}
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,2 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });

View File

@@ -29,6 +29,7 @@
<link rel="stylesheet" href="recommendations.css" />
<link rel="stylesheet" href="settings-sections.css" />
<link rel="stylesheet" href="lower-third.css" />
<link rel="stylesheet" href="car-mode.css" />
</head>
<body>
<div class="app">
@@ -662,6 +663,7 @@
<script src="lower-third.js"></script>
<script src="vocal-reducer.js"></script>
<script src="setlist-import.js"></script>
<script src="car-mode.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -90,6 +90,8 @@ const SHELL = [
'/lower-third.js',
'/vocal-reducer.js',
'/setlist-import.js',
'/car-mode.js',
'/car-mode.css',
'/lower-third.css',
'/settings-sections.css',
'/app.js',

View File

@@ -49,3 +49,10 @@ test('setlist import confirms library matches and retains key tags', async ({ pa
await page.getByRole('button', { name: 'Create setlist', exact: true }).click();
expect(await page.evaluate(() => data.playlists.at(-1).videos[0].tags)).toContain('Key E');
});
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) test(`${layout} Car mode at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await openClassic(page); await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); CarMode.open(); }, layout);
await expect(page.getByRole('dialog', { name: 'Car mode' })).toBeVisible();
expect(await page.locator('.car-controls button').first().evaluate(el => el.getBoundingClientRect().height)).toBeGreaterThanOrEqual(100);
expect(await page.locator('.app').evaluate(el => el.inert)).toBe(true);
await page.getByRole('button', { name: 'Exit Car mode' }).click(); await expect(page.locator('.car-mode')).toHaveCount(0); expect(await page.locator('.app').evaluate(el => el.inert)).toBe(false);
});