gum_picker_output_fallback.html (267 lines of code) (raw):

<html> <meta charset=utf-8> <table> <tr> <td> <button id="start">Start!</button><button id="halt">Stop!</button><br><br> <table> <tr> <td>Camera:</td> <td><select id="camera" disabled></select></td> </tr> <tr> <td>Microphone:</td> <td><select id="microphone" disabled></select></td> </tr> <tr> <td>Speakers:</td> <td> <span id="newapi"> <button id="speakers1">Default system output...</button> <button id="reset" hidden>Reset</button> </span> <span id="oldapi" style="display: none;"> <select id="speakers2"> <option value="">Default system output</option> </select> </span> </td> </tr> </table><br><br><br> </td> <td> <video id="video" width="266" height="200" autoplay controls></video><canvas id="canvas"></canvas><br> </td> </tr> </table> <div id="div"></div><br> <script> const console = {log: msg => div.innerHTML += `${msg}<br>`}; if (!("selectAudioOutput" in navigator.mediaDevices)) { newapi.style.display = "none"; oldapi.style.display = "inline"; } if (!localStorage.speakersLabel) { localStorage.speakersLabel = "Default system output"; } speakers1.innerText = `${localStorage.speakersLabel}...`; if (localStorage.speakers) { reset.hidden = false; } async function getUserMedia(constraints) { const stream = await navigator.mediaDevices.getUserMedia(constraints); for (const track of stream.getTracks()) { localStorage[track.kind] = track.getSettings().deviceId; localStorage[track.kind + "Label"] = track.label; track.addEventListener("ended", async () => { try { console.log(`${track.label}'s ${track.kind} track ended.`); console.log(`Manually fall back to any available ${track.kind} device.`); const [newTrack] = (await replaceUserMedia({audio: true})).getAudioTracks(); video.srcObject = spectrum(new MediaStream([newTrack, ...video.srcObject.getVideoTracks()])); } catch (e) { console.log(e); } }, {once:true}); } return stream; } async function selectAudioOutput(options) { if ("selectAudioOutput" in navigator.mediaDevices) { return await navigator.mediaDevices.selectAudioOutput(options); } return options; } async function setSinkId({deviceId, label = "Default system output"}) { if (deviceId == video.sinkId) return; console.log(`Switching output to ${label}`); await video.setSinkId(deviceId); speakers1.innerText = `${label}...`; localStorage.speakers = deviceId; localStorage.speakersLabel = label; reset.hidden = !deviceId.length; } start.onclick = async () => { try { video.srcObject = spectrum(await getUserMedia({audio: {deviceId: localStorage.audio}, video: {deviceId: localStorage.video}})); if (localStorage.speakers) { await setSinkId(await selectAudioOutput({deviceId: localStorage.speakers, label: localStorage.speakersLabel})); } updateSelectors(); camera.disabled = microphone.disabled = false; } catch (e) { console.log(e); } }; halt.onclick = () => { for (const track of video.srcObject.getTracks()) { track.stop(); } } async function replaceUserMedia(constraints, ...oldTracks) { try { const stream = await getUserMedia(constraints); for (const track of oldTracks) { track.stop(); } return stream; } catch (e) { if (e.name != "NotReadableError") throw e; for (const track of oldTracks) { track.stop(); } return await getUserMedia(constraints); } } microphone.onchange = async () => { try { const [oldTrack] = video.srcObject.getAudioTracks(); const [newTrack] = (await replaceUserMedia({audio: {deviceId: {exact: microphone.value}}}, oldTrack)).getAudioTracks(); video.srcObject = spectrum(new MediaStream([newTrack, ...video.srcObject.getVideoTracks()])); } catch (e) { console.log(e); updateSelectors(); } }; camera.onchange = async () => { try { const [oldTrack] = video.srcObject.getVideoTracks(); const [newTrack] = (await replaceUserMedia({video: {deviceId: {exact: camera.value}}}, oldTrack)).getVideoTracks(); video.srcObject = new MediaStream([newTrack, ...video.srcObject.getAudioTracks()]); } catch (e) { console.log(e); updateSelectors(); } }; speakers1.onclick = async () => { try { await setSinkId(await selectAudioOutput()); } catch (e) { console.log(e); } }; reset.onclick = async () => { try { await setSinkId({deviceId: ""}); } catch (e) { console.log(e); } }; speakers2.onchange = async () => { try { let label = "", deviceId = speakers2.value; for (const option of speakers2.options) { if (speakers2.value == option.value) label = option.innerText; } if (deviceId == "other") { ({deviceId, label} = await selectAudioOutput({deviceId})); } localStorage.speakers = deviceId; localStorage.speakersLabel = label; await setSinkId({deviceId, label}); updateSelectors(); } catch (e) { console.log(e); updateSelectors(); } }; navigator.mediaDevices.ondevicechange = async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); if (!devices.find(({deviceId}) => deviceId == video.sinkId)) { reset.onclick(); } const camCount = camera.childElementCount; const micCount = microphone.childElementCount; const spkCount = speakers2.childElementCount; await updateSelectors(); if (camera.childElementCount > camCount) console.log("New camera available"); if (microphone.childElementCount > micCount) console.log("New microphone available"); if (speakers2.childElementCount > spkCount) console.log("New speakers available"); } catch (e) { console.log(e); } } async function updateSelectors() { try { const selectedIds = (video.srcObject?.getTracks() || []).map(t => t.getSettings().deviceId); if (video.sinkId) { selectedIds.push(video.sinkId); } const devices = await navigator.mediaDevices.enumerateDevices(); for (const [s, type, kind, name] of [[camera, "video", "videoinput", "Camera"], [microphone, "audio", "audioinput", "Microphone"], [speakers2, "speakers", "audiooutput", "Speakers"]]) { let old = s.value; while (s.firstChild) s.removeChild(s.firstChild); let i = 0; for (const device of devices) { if (device.kind != kind) continue; const option = document.createElement('option'); option.value = device.deviceId; i++; const match = device.deviceId == localStorage[type]; option.text = device.label || (match && localStorage[type+"Label"]) || `${name} ${i}`; if (match) old = option.value; s.appendChild(option); } if ([...s.options].map(({value}) => value).includes(old)) s.value = old; for (const option of s.options) { if (selectedIds.includes(option.value)) s.value = option.value; } } if (!speakers2.children.length && localStorage.speakers?.length) { const option = document.createElement('option'); option.value = localStorage.speakers; option.text = localStorage.speakersLabel; speakers2.appendChild(option); speakers2.value = option.value; } } catch (e) { console.log(e); } } updateSelectors(); function spectrum(stream) { const audioCtx = new AudioContext(); const analyser = audioCtx.createAnalyser(); audioCtx.createMediaStreamSource(stream).connect(analyser); canvas.width = 200; canvas.height = 200; const ctx = canvas.getContext("2d"); const data = new Uint8Array(canvas.width); ctx.strokeStyle = 'rgb(0, 125, 0)'; const interval = setInterval(() => { ctx.fillStyle = "#a0a0a0"; ctx.fillRect(0, 0, canvas.width, canvas.height); analyser.getByteFrequencyData(data); ctx.lineWidth = 2; let x = 0; for (let d of data) { const y = canvas.height - (d / 128) * canvas.height / 4; const c = Math.floor((x*255)/canvas.width); ctx.fillStyle = `rgb(${c},0,${255-x})`; ctx.fillRect(x++, y, 2, canvas.height - y) } analyser.getByteTimeDomainData(data); ctx.lineWidth = 5; ctx.beginPath(); x = 0; for (let d of data) { const y = canvas.height - (d / 128) * canvas.height / 2; x ? ctx.lineTo(x++, y) : ctx.moveTo(x++, y); } ctx.stroke(); }, 1000 * canvas.width / audioCtx.sampleRate); const cleanup = () => { audioCtx.close(); clearInterval(interval); }; const [track] = stream.getAudioTracks(); track.addEventListener("ended", cleanup, {once: true}); track.stop_ = track.stop; track.stop = () => track.stop_(cleanup()); return stream; }; </script> </html>