Implement full NFC-URL streaming architecture

- rc522: add NDEF URL reading via WUPA+cascade anticoll+SELECT+TLV parse
- AudioPlayer: route http:// URIs to esp_player_http_music_play()
- CacheManager: download URLs async to /sdcard/cache/{hash}.{ext}
- TagPlayerControl: NDEF URL → cache-or-stream; fallback to UID local mapping
- RFIDInterface: wire ndef_callback, expose combined uid+url callback
- WebInterface: add /api/info, /api/storage, /api/playback, /api/bluetooth
- BluetoothInterface: stub for A2DP speaker mode
- Networking: WiFi connect with 30s timeout instead of blocking forever
- main.cpp: re-enable WiFi + web server, fix init order, add CacheManager
- www/: full SPA — playback controls, device info, storage, BT pairing
- .gitmodules: switch rc522 submodule URL to HTTPS

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
This commit is contained in:
Claude committed 2026-06-12 19:44:54 +00:00
1 parent 98872d75df
commit bf9852c345
19 files changed
+1172 -318

No files matched your search

+148 -9
View File
@@ -1,13 +1,152 @@
console.log('hi');
'use strict';
const elements = {};
/* ── helpers ──────────────────────────────────────────────────────────── */
elements.rfidId = document.querySelector('.rfid-id');
elements.rfidRefresh = document.querySelector('.rfid-refresh');
function fmt_bytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
elements.rfidRefresh.addEventListener('click', async () => {
const response = await fetch('/rfid'),
rfidId = await response.text();
async function api(method, path, body) {
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
opts.body = body;
}
const res = await fetch(path, opts);
return res.json();
}
elements.rfidId.innerText = rfidId;
});
/* ── device info ──────────────────────────────────────────────────────── */
async function refreshInfo() {
try {
const d = await api('GET', '/api/info');
document.getElementById('info-ip').textContent = d.ip ?? '—';
document.getElementById('info-playing').textContent = d.playing ? 'Playing' : 'Stopped';
document.getElementById('info-cache').textContent = fmt_bytes(d.cacheBytes ?? 0);
document.getElementById('info-heap').textContent = fmt_bytes(d.freeHeap ?? 0);
document.getElementById('track-uri').textContent = d.currentUri || '—';
document.getElementById('btn-pause').disabled = !d.playing;
document.getElementById('btn-stop').disabled = !d.playing;
const vol = d.volume ?? 50;
document.getElementById('volume-slider').value = vol;
document.getElementById('volume-label').textContent = vol;
} catch (e) {
console.error('Failed to fetch /api/info', e);
}
}
/* ── storage ──────────────────────────────────────────────────────────── */
async function refreshStorage() {
try {
const entries = await api('GET', '/api/storage');
const list = document.getElementById('cache-list');
const summary = document.getElementById('storage-summary');
list.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
summary.textContent = 'No cached files';
return;
}
let total = 0;
entries.forEach(e => {
total += e.size ?? 0;
const li = document.createElement('li');
const key = document.createElement('span');
key.className = 'cache-key';
key.textContent = e.key;
const sz = document.createElement('span');
sz.className = 'cache-size';
sz.textContent = fmt_bytes(e.size ?? 0);
const del = document.createElement('button');
del.className = 'btn btn-danger';
del.style.cssText = 'padding:.2rem .5rem;font-size:.75rem';
del.textContent = '✕';
del.addEventListener('click', () => deleteEntry(e.key));
li.append(key, sz, del);
list.appendChild(li);
});
summary.textContent =
`${entries.length} file${entries.length !== 1 ? 's' : ''} — ${fmt_bytes(total)}`;
} catch (e) {
console.error('Failed to fetch /api/storage', e);
}
}
async function deleteEntry(key) {
await api('DELETE', `/api/storage?key=${encodeURIComponent(key)}`);
refreshStorage();
}
async function clearCache() {
if (!confirm('Delete all cached files?')) return;
await api('DELETE', '/api/storage');
refreshStorage();
}
/* ── playback ─────────────────────────────────────────────────────────── */
async function sendPlayback(action, value) {
const body = value !== undefined
? `action=${action}&value=${encodeURIComponent(value)}`
: `action=${action}`;
await api('POST', '/api/playback', body);
refreshInfo();
}
/* ── bluetooth ────────────────────────────────────────────────────────── */
async function refreshBt() {
try {
const d = await api('GET', '/api/bluetooth');
const status = document.getElementById('bt-status');
const btn = document.getElementById('btn-bt-toggle');
if (d.enabled) {
status.textContent = 'Enabled';
status.style.color = '#48bb78';
btn.textContent = 'Disable';
} else {
status.textContent = 'Disabled';
status.style.color = '#a0aec0';
btn.textContent = 'Enable';
}
} catch (e) {
console.error('Failed to fetch /api/bluetooth', e);
}
}
async function toggleBt() {
const enabled = document.getElementById('bt-status').textContent === 'Enabled';
await api('POST', '/api/bluetooth', `enabled=${!enabled}`);
refreshBt();
}
/* ── init ─────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
refreshInfo();
refreshStorage();
refreshBt();
document.getElementById('btn-refresh-info').addEventListener('click', refreshInfo);
document.getElementById('btn-clear-cache').addEventListener('click', clearCache);
document.getElementById('btn-bt-toggle').addEventListener('click', toggleBt);
document.getElementById('btn-pause').addEventListener('click', () => sendPlayback('pause'));
document.getElementById('btn-stop').addEventListener('click', () => sendPlayback('stop'));
const slider = document.getElementById('volume-slider');
const label = document.getElementById('volume-label');
slider.addEventListener('input', () => { label.textContent = slider.value; });
slider.addEventListener('change', () => sendPlayback('volume', slider.value));
/* Auto-refresh info every 5 s while page is visible */
setInterval(() => { if (!document.hidden) refreshInfo(); }, 5000);
});