mirror of
https://github.com/sweidac/kidsbox.git
synced 2026-10-28 06:52:57 +01:00
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:
19 files changed
+1172
-318
No files matched your search
+57
-15
@@ -1,17 +1,59 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Kidsbox</title>
|
||||
<link rel="stylesheet" src="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hi, I'm a File!</h1>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kidsbox</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🎧 Kidsbox</h1>
|
||||
</header>
|
||||
|
||||
<section class="rfid">
|
||||
<h3>RFID-ID</h3>
|
||||
<div class="rfid-id"></div>
|
||||
<button class="rfid-refresh">refresh</button>
|
||||
</section>
|
||||
<main>
|
||||
<section class="card" id="section-playback">
|
||||
<h2>Now Playing</h2>
|
||||
<div class="track-uri" id="track-uri">—</div>
|
||||
<div class="playback-controls">
|
||||
<button class="btn btn-primary" id="btn-pause" disabled>Pause</button>
|
||||
<button class="btn btn-secondary" id="btn-stop" disabled>Stop</button>
|
||||
</div>
|
||||
<div class="volume-row">
|
||||
<label for="volume-slider">Volume</label>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
||||
<span id="volume-label">50</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<section class="card" id="section-info">
|
||||
<h2>Device Info</h2>
|
||||
<table class="info-table">
|
||||
<tr><th>IP Address</th><td id="info-ip">—</td></tr>
|
||||
<tr><th>Status</th><td id="info-playing">—</td></tr>
|
||||
<tr><th>Cache Used</th><td id="info-cache">—</td></tr>
|
||||
<tr><th>Free Heap</th><td id="info-heap">—</td></tr>
|
||||
</table>
|
||||
<button class="btn btn-secondary" id="btn-refresh-info">Refresh</button>
|
||||
</section>
|
||||
|
||||
<section class="card" id="section-storage">
|
||||
<h2>Storage</h2>
|
||||
<div class="storage-header">
|
||||
<span id="storage-summary">Loading…</span>
|
||||
<button class="btn btn-danger" id="btn-clear-cache">Clear All</button>
|
||||
</div>
|
||||
<ul class="cache-list" id="cache-list"></ul>
|
||||
</section>
|
||||
|
||||
<section class="card" id="section-bluetooth">
|
||||
<h2>Bluetooth Speaker</h2>
|
||||
<p class="bt-status" id="bt-status">Disabled</p>
|
||||
<button class="btn btn-secondary" id="btn-bt-toggle">Enable</button>
|
||||
<p class="note">Pair your phone to play music through the Kidsbox speaker.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+148
-9
@@ -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);
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0f4f8;
|
||||
color: #1a202c;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #2d3748;
|
||||
color: #fff;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 700px;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: #718096;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Playback */
|
||||
.track-uri {
|
||||
font-size: .9rem;
|
||||
color: #4a5568;
|
||||
word-break: break-all;
|
||||
min-height: 1.2em;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.volume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.volume-row label { font-size: .875rem; color: #718096; white-space: nowrap; }
|
||||
.volume-row input { flex: 1; }
|
||||
#volume-label { font-size: .875rem; width: 2rem; text-align: right; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: .45rem .9rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn:not(:disabled):hover { opacity: .85; }
|
||||
|
||||
.btn-primary { background: #4299e1; color: #fff; }
|
||||
.btn-secondary { background: #e2e8f0; color: #2d3748; }
|
||||
.btn-danger { background: #fc8181; color: #fff; }
|
||||
|
||||
/* Device info */
|
||||
.info-table { width: 100%; border-collapse: collapse; margin-bottom: .75rem; }
|
||||
.info-table th, .info-table td { padding: .35rem .5rem; text-align: left; font-size: .875rem; }
|
||||
.info-table th { color: #718096; width: 40%; }
|
||||
.info-table tr:not(:last-child) th,
|
||||
.info-table tr:not(:last-child) td { border-bottom: 1px solid #edf2f7; }
|
||||
|
||||
/* Storage */
|
||||
.storage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .75rem;
|
||||
font-size: .875rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.cache-list { list-style: none; }
|
||||
|
||||
.cache-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .4rem 0;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.cache-list li:last-child { border-bottom: none; }
|
||||
|
||||
.cache-list .cache-key { font-family: monospace; color: #4a5568; }
|
||||
.cache-list .cache-size { color: #a0aec0; }
|
||||
|
||||
/* Bluetooth */
|
||||
.bt-status { font-weight: 600; margin-bottom: .5rem; }
|
||||
#btn-bt-toggle { margin-bottom: .5rem; }
|
||||
.note { font-size: .8rem; color: #a0aec0; }
|
||||
Reference in new issue
Block a user