add app, fix ble, use flash
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
// 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 =
|
||||
`<b>${file.name}</b> → ${bits}-bit ${rate / 1000} kHz mono<br>` +
|
||||
`${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 = `<li class="empty">${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of rows) {
|
||||
const li = document.createElement('li');
|
||||
const title = t.name || `Track ${t.idx}`;
|
||||
li.innerHTML =
|
||||
`<div class="slot-meta"><span class="slot-num">${t.idx}</span>` +
|
||||
`<div><b>${title}</b><small>${t.bits}-bit ${t.rate / 1000}kHz · ${(+t.durationSec).toFixed(1)}s</small></div></div>`;
|
||||
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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user