// app.js — UI glue for the BabyMobile manager PWA. import { BabyMobile, isSupported, MAX_TRACKS } from './ble.js'; import { fileToWav, RATES, BITS } from './wav.js'; const $ = (id) => document.getElementById(id); const dev = new BabyMobile(); // Slot metadata is kept locally because the firmware can't enumerate tracks // over BLE. This remembers what *we* uploaded so the UI can show slot contents. const SLOTS_KEY = 'babymobile.slots.v1'; let slots = loadSlots(); let pendingWav = null; // { wav, durationSec, rate, bits, name } let deviceTracks = null; // last result of dev.listTracks(), or null function loadSlots() { try { return JSON.parse(localStorage.getItem(SLOTS_KEY)) || {}; } catch { return {}; } } function saveSlots() { localStorage.setItem(SLOTS_KEY, JSON.stringify(slots)); } function log(msg) { const el = $('log'); const time = new Date().toLocaleTimeString(); el.textContent += `[${time}] ${msg}\n`; el.scrollTop = el.scrollHeight; } function fmtBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(2)} MB`; } // ---- connection state ---- dev.addEventListener('log', (e) => log(e.detail)); dev.addEventListener('connected', (e) => { setConnected(true, e.detail.name); refresh(); }); dev.addEventListener('disconnected', () => { setConnected(false); deviceTracks = null; renderSlots(); }); function setConnected(on, name) { $('status-dot').className = on ? 'dot on' : 'dot'; $('status-text').textContent = on ? `Connected${name ? ' · ' + name : ''}` : 'Not connected'; $('btn-connect').textContent = on ? 'Disconnect' : 'Scan & connect'; document.querySelectorAll('[data-needs-conn]').forEach((el) => { el.disabled = !on; }); } $('btn-connect').addEventListener('click', async () => { try { if (dev.connected) await dev.disconnect(); else await dev.connect(); } catch (err) { if (err && err.name === 'NotFoundError') log('Scan cancelled.'); else log('Error: ' + (err && err.message ? err.message : err)); } }); // ---- file selection & conversion ---- const fileInput = $('file'); $('drop').addEventListener('click', () => fileInput.click()); $('drop').addEventListener('dragover', (e) => { e.preventDefault(); $('drop').classList.add('hover'); }); $('drop').addEventListener('dragleave', () => $('drop').classList.remove('hover')); $('drop').addEventListener('drop', (e) => { e.preventDefault(); $('drop').classList.remove('hover'); if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); }); async function handleFile(file) { const rate = parseInt($('rate').value, 10); const bits = parseInt($('bits').value, 10); $('convert-info').textContent = `Converting "${file.name}"…`; pendingWav = null; $('btn-upload').disabled = true; try { const res = await fileToWav(file, { rate, bits }); pendingWav = { ...res, name: file.name }; const sec = res.durationSec.toFixed(1); $('convert-info').innerHTML = `${file.name} → ${bits}-bit ${rate / 1000} kHz mono
` + `${sec}s · ${fmtBytes(res.wav.byteLength)} (incl. 44-byte WAV header)`; $('btn-upload').disabled = !dev.connected; } catch (err) { $('convert-info').textContent = 'Could not decode this file: ' + (err.message || err) + '. Try a WAV/MP3/M4A the browser can decode.'; } } // Re-convert if the format options change while a file is staged. ['rate', 'bits'].forEach((id) => $(id).addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); })); // ---- upload ---- $('btn-upload').addEventListener('click', async () => { if (!pendingWav) return; const track = parseInt($('upload-slot').value, 10); $('btn-upload').disabled = true; const bar = $('upload-bar'); bar.style.width = '0%'; bar.parentElement.classList.add('active'); try { const { ok, acked } = await dev.upload(track, pendingWav.wav, (sent, total) => { bar.style.width = `${Math.round((sent / total) * 100)}%`; }); if (ok) { slots[track] = { name: pendingWav.name, bits: pendingWav.bits, rate: pendingWav.rate, durationSec: pendingWav.durationSec, bytes: pendingWav.wav.byteLength, at: Date.now(), }; saveSlots(); await refresh(); // pull the authoritative list back from the device } } catch (err) { log('Upload failed: ' + (err.message || err)); } finally { bar.parentElement.classList.remove('active'); $('btn-upload').disabled = !dev.connected; } }); // ---- playback ---- $('btn-play').addEventListener('click', () => dev.play(parseInt($('play-slot').value, 10)).catch((e) => log('' + e))); $('btn-stop').addEventListener('click', () => dev.stop().catch((e) => log('' + e))); // ---- track list ---- $('btn-refresh').addEventListener('click', () => refresh()); async function refresh() { if (!dev.connected) return; try { deviceTracks = await dev.listTracks(); log(`Device reports ${deviceTracks.length} track(s).`); renderSlots(); } catch (err) { log('List failed: ' + (err.message || err)); } } async function onDelete(idx, onDevice) { if (onDevice) { try { const r = await dev.deleteTrack(idx); if (r.success) { delete slots[idx]; saveSlots(); log(`🗑 Deleted slot ${idx}.`); await refresh(); } else { log(`Device could not delete slot ${idx}.`); } } catch (err) { log('Delete failed: ' + (err.message || err)); } } else { delete slots[idx]; saveSlots(); renderSlots(); } } function renderSlots() { const list = $('slots'); list.innerHTML = ''; const onDevice = dev.connected && Array.isArray(deviceTracks); // When connected, the device list is the source of truth (annotated with local // names). Otherwise fall back to the local record of past uploads. const rows = onDevice ? deviceTracks.map((t) => ({ ...t, name: slots[t.idx]?.name })) : Object.keys(slots).map(Number).sort((a, b) => a - b).map((k) => ({ idx: k, bits: slots[k].bits, rate: slots[k].rate, durationSec: slots[k].durationSec, name: slots[k].name, })); if (!rows.length) { list.innerHTML = `
  • ${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}
  • `; return; } for (const t of rows) { const li = document.createElement('li'); const title = t.name || `Track ${t.idx}`; li.innerHTML = `
    ${t.idx}` + `
    ${title}${t.bits}-bit ${t.rate / 1000}kHz · ${(+t.durationSec).toFixed(1)}s
    `; const actions = document.createElement('div'); actions.className = 'slot-actions'; const playBtn = btn('▶', 'Play', () => dev.play(t.idx).catch((e) => log('' + e))); playBtn.dataset.needsConn = ''; playBtn.disabled = !dev.connected; const delBtn = btn('🗑', onDevice ? 'Delete from device' : 'Forget (local only)', () => onDelete(t.idx, onDevice)); actions.append(playBtn, delBtn); li.append(actions); list.append(li); } } function btn(label, title, fn) { const b = document.createElement('button'); b.textContent = label; b.title = title; b.className = 'mini'; b.addEventListener('click', fn); return b; } // ---- populate selectors ---- function fillSelect(sel, values, fmt, selected) { sel.innerHTML = ''; for (const v of values) { const o = document.createElement('option'); o.value = v; o.textContent = fmt(v); if (v === selected) o.selected = true; sel.append(o); } } fillSelect($('rate'), RATES, (v) => `${v / 1000} kHz`, 8000); fillSelect($('bits'), BITS, (v) => `${v}-bit`, 8); for (const sel of [$('upload-slot'), $('play-slot')]) { fillSelect(sel, Array.from({ length: MAX_TRACKS }, (_, i) => i), (v) => `Slot ${v}`, 0); } // ---- support banner ---- if (!isSupported()) { $('unsupported').hidden = false; $('btn-connect').disabled = true; } renderSlots(); setConnected(false); // ---- PWA service worker ---- if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js').catch(() => {}); }