feat(06-01): add YouTube URL input with terminal progress bar

- Add yt-url input, FETCH button, and yt-progress span to transport bar
- Add fetchWithProgress() SSE ReadableStream reader function
- Add renderProgressBar() terminal-style block fill renderer (DOWNLOADING ████)
- Wire FETCH button click handler with D-02 disable-during-download behavior
- Auto-load audio into player after download (D-01 same pipeline as upload)
- Add .yt-url-input and .yt-progress CSS styles (flat, monospace, no border-radius)
- Enable yt-url input on page load via JS
This commit is contained in:
Claude
2026-04-07 11:37:10 +00:00
parent d58ea8c633
commit dca1cb837b
3 changed files with 116 additions and 0 deletions

View File

@@ -284,6 +284,90 @@ document.getElementById('audio-upload')?.addEventListener('change', async (e) =>
e.target.value = '';
});
// ── YouTube fetch ────────────────────────────────────────────────────────────
async function fetchWithProgress(url, body, onProgress, onDone, onError) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n\n');
buf = lines.pop();
for (const chunk of lines) {
if (chunk.startsWith('data: ')) {
const msg = JSON.parse(chunk.slice(6));
if (msg.type === 'progress') onProgress(msg.pct);
if (msg.type === 'done') onDone(msg);
if (msg.type === 'error') onError(msg.msg);
}
}
}
}
function renderProgressBar(pct, width = 20) {
const filled = Math.round((pct / 100) * width);
const empty = width - filled;
return `DOWNLOADING ${'█'.repeat(filled)}${'░'.repeat(empty)} ${Math.round(pct)}%`;
}
document.getElementById('btn-fetch')?.addEventListener('click', async () => {
const urlInput = document.getElementById('yt-url');
const btn = document.getElementById('btn-fetch');
const progress = document.getElementById('yt-progress');
const url = urlInput?.value.trim();
if (!url) return;
// D-02: Disable input during download
urlInput.disabled = true;
btn.disabled = true;
btn.textContent = '...';
progress.style.display = 'inline';
progress.textContent = 'DOWNLOADING ░░░░░░░░░░░░░░░░░░░░ 0%';
try {
await fetchWithProgress('/api/audio/fetch', { url },
(pct) => {
progress.textContent = renderProgressBar(pct);
},
(msg) => {
// D-01: After download, load identically to local upload
progress.textContent = 'COMPLETE';
const audio = document.getElementById('player');
if (audio && msg.filename) {
audio.src = `/shows/${msg.filename}`;
audio.load();
}
refreshFiles(msg.path);
setTimeout(() => { progress.style.display = 'none'; }, 2000);
},
(errMsg) => {
progress.textContent = `ERROR: ${errMsg}`;
progress.style.color = '#ff4444';
setTimeout(() => {
progress.style.display = 'none';
progress.style.color = '';
}, 5000);
}
);
} catch (e) {
progress.textContent = `ERROR: ${e.message}`;
setTimeout(() => { progress.style.display = 'none'; }, 5000);
} finally {
urlInput.disabled = false;
urlInput.value = '';
btn.disabled = false;
btn.textContent = 'FETCH';
}
});
// ── Load selected ────────────────────────────────────────────────────────────
document.getElementById('btn-load')?.addEventListener('click', async () => {
@@ -349,6 +433,7 @@ document.getElementById('add-device-form')?.addEventListener('submit', async (e)
wireAudio();
wireKeyboard(); // keyboard shortcuts (Phase 5)
document.getElementById('yt-url').disabled = false;
// ── Preview strip builder ────────────────────────────────────────────────────