Preserve separate Car mode and voice controls during lazy setup
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
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.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
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(); };
|
||||
@@ -23,7 +23,7 @@
|
||||
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.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; 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>'; if(!car.isConnected)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.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
|
||||
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);
|
||||
|
||||
@@ -1,2 +1,26 @@
|
||||
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({}), ''); });
|
||||
|
||||
test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => {
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
class Element {
|
||||
constructor(tag){this.tag=tag;this.children=[];this.inert=false;}
|
||||
append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}}
|
||||
remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}}
|
||||
after(node){this.parent.append(node);}
|
||||
setAttribute(name,value){this[name]=value;}
|
||||
focus(){}
|
||||
get isConnected(){return !!this.parent;}
|
||||
}
|
||||
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
||||
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
||||
let section;const actions=[],root={document:doc,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
||||
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
||||
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
||||
assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body);
|
||||
controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']);
|
||||
root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false);
|
||||
const panel=new Element('section');body.append(panel);section.render(panel);
|
||||
assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user