diff --git a/frontend/app.js b/frontend/app.js index 0c213a2..a8881c0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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(); diff --git a/frontend/car-mode.css b/frontend/car-mode.css new file mode 100644 index 0000000..8d869cf --- /dev/null +++ b/frontend/car-mode.css @@ -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; } diff --git a/frontend/car-mode.js b/frontend/car-mode.js new file mode 100644 index 0000000..9785ce1 --- /dev/null +++ b/frontend/car-mode.js @@ -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 = ''; 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 = ''; 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); diff --git a/frontend/car-mode.test.js b/frontend/car-mode.test.js new file mode 100644 index 0000000..ae51c4e --- /dev/null +++ b/frontend/car-mode.test.js @@ -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({}), ''); }); diff --git a/frontend/index.html b/frontend/index.html index 3fded4c..77bc9cf 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -29,6 +29,7 @@ +
@@ -662,6 +663,7 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index 6d9f516..3cec2b6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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', diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js index 32c0745..72a65d5 100644 --- a/tests/settings-sections.spec.js +++ b/tests/settings-sections.spec.js @@ -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); +});