Initial commit
This commit is contained in:
+283
@@ -0,0 +1,283 @@
|
||||
const DB_NAME = 'voice-kb-local';
|
||||
const STORE_NAME = 'recordings';
|
||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||
const elements = Object.fromEntries([
|
||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
|
||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'search-input',
|
||||
'network-state', 'date-label', 'storage-status',
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
|
||||
let databasePromise;
|
||||
let mediaRecorder;
|
||||
let activeStream;
|
||||
let recordingChunks = [];
|
||||
let recordingStartedAt = 0;
|
||||
let recordingTimer;
|
||||
let searchTimer;
|
||||
let syncing = false;
|
||||
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
|
||||
async function queueTransaction(mode, operation) {
|
||||
const db = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = operation(store);
|
||||
transaction.oncomplete = () => resolve(request?.result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
|
||||
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
|
||||
const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id));
|
||||
|
||||
function setMessage(message, isError = false) {
|
||||
elements['capture-message'].textContent = message;
|
||||
elements['capture-message'].style.color = isError ? 'var(--coral)' : '';
|
||||
}
|
||||
|
||||
function updateNetwork() {
|
||||
const online = navigator.onLine;
|
||||
elements['network-state'].classList.toggle('online', online);
|
||||
elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`;
|
||||
elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.';
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function preferredMimeType() {
|
||||
return ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
|
||||
.find((type) => window.MediaRecorder?.isTypeSupported(type));
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||
setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = preferredMimeType();
|
||||
mediaRecorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
|
||||
recordingChunks = [];
|
||||
mediaRecorder.ondataavailable = (event) => { if (event.data.size) recordingChunks.push(event.data); };
|
||||
mediaRecorder.onstop = saveRecording;
|
||||
mediaRecorder.start(1000);
|
||||
recordingStartedAt = Date.now();
|
||||
elements['record-button'].classList.add('recording');
|
||||
elements['record-button'].setAttribute('aria-label', 'Stop recording');
|
||||
elements['record-title'].textContent = 'Recording in progress';
|
||||
elements['record-help'].textContent = 'Tap the square to finish and save';
|
||||
setMessage('Audio is being recorded on this device.');
|
||||
recordingTimer = window.setInterval(() => {
|
||||
const elapsed = Date.now() - recordingStartedAt;
|
||||
elements['record-time'].textContent = formatDuration(elapsed);
|
||||
elements['record-meter-fill'].style.width = `${Math.min(100, elapsed / MAX_RECORDING_MS * 100)}%`;
|
||||
if (elapsed >= MAX_RECORDING_MS) stopRecording();
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
|
||||
activeStream?.getTracks().forEach((track) => track.stop());
|
||||
activeStream = undefined;
|
||||
clearInterval(recordingTimer);
|
||||
elements['record-button'].classList.remove('recording');
|
||||
elements['record-button'].setAttribute('aria-label', 'Start recording');
|
||||
elements['record-title'].textContent = 'Recording saved';
|
||||
elements['record-help'].textContent = 'Adding it to the upload queue';
|
||||
elements['record-time'].textContent = '00:00';
|
||||
elements['record-meter-fill'].style.width = '0';
|
||||
}
|
||||
|
||||
async function saveRecording() {
|
||||
const blob = new Blob(recordingChunks, { type: mediaRecorder.mimeType || 'audio/mp4' });
|
||||
if (!blob.size) {
|
||||
setMessage('No audio was captured. Please try again.', true);
|
||||
return;
|
||||
}
|
||||
const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : 'm4a';
|
||||
const createdAt = new Date(recordingStartedAt).toISOString();
|
||||
const recording = {
|
||||
id: crypto.randomUUID(),
|
||||
blob,
|
||||
createdAt,
|
||||
filename: `voice-note-${createdAt.replaceAll(':', '-')}.${extension}`,
|
||||
durationMs: Date.now() - recordingStartedAt,
|
||||
createdAtMs: recordingStartedAt,
|
||||
};
|
||||
try {
|
||||
await putQueued(recording);
|
||||
setMessage(navigator.onLine ? 'Saved on device. Uploading now…' : 'Saved on device. It will upload when you reconnect.');
|
||||
await renderQueue();
|
||||
await syncQueue();
|
||||
} catch {
|
||||
setMessage('Could not save this recording in browser storage. Keep this page open and try again.', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncQueue() {
|
||||
if (syncing || !navigator.onLine) return;
|
||||
syncing = true;
|
||||
elements['sync-button'].disabled = true;
|
||||
try {
|
||||
const recordings = await getQueued();
|
||||
for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) {
|
||||
const form = new FormData();
|
||||
form.append('recording_id', recording.id);
|
||||
form.append('audio', recording.blob, recording.filename);
|
||||
form.append('created_at', recording.createdAt);
|
||||
try {
|
||||
const response = await fetch('/api/notes', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||
await deleteQueued(recording.id);
|
||||
setMessage('Server confirmed the recording is stored.');
|
||||
} catch {
|
||||
setMessage('Upload paused. Your recording is still saved on this device.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncing = false;
|
||||
elements['sync-button'].disabled = false;
|
||||
await renderQueue();
|
||||
await loadArchive(elements['search-input'].value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function prettyDate(value) {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
|
||||
}
|
||||
|
||||
async function renderQueue() {
|
||||
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
elements['queue-list'].replaceChildren();
|
||||
elements['queue-count'].textContent = recordings.length;
|
||||
elements['queue-empty'].hidden = recordings.length > 0;
|
||||
for (const recording of recordings) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'queue-item';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'queue-icon';
|
||||
icon.textContent = '◖';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'item-body';
|
||||
const top = document.createElement('div');
|
||||
top.className = 'item-top';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'item-name';
|
||||
name.textContent = recording.filename;
|
||||
const status = document.createElement('span');
|
||||
status.className = 'item-status';
|
||||
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'item-meta';
|
||||
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
|
||||
top.append(name, status);
|
||||
body.append(top, meta);
|
||||
item.append(icon, body);
|
||||
elements['queue-list'].append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchive(notes) {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = notes.length > 0;
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to browse or search stored notes.';
|
||||
else elements['archive-empty'].textContent = elements['search-input'].value.trim() ? 'No matching transcripts.' : 'Your stored notes will appear here.';
|
||||
for (const note of notes) {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'archive-item';
|
||||
const date = document.createElement('time');
|
||||
date.className = 'archive-date';
|
||||
date.textContent = prettyDate(note.created_at);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'item-body';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'item-name';
|
||||
name.textContent = note.filename;
|
||||
const transcript = document.createElement('textarea');
|
||||
transcript.className = 'archive-transcript';
|
||||
transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||
transcript.value = note.transcript;
|
||||
transcript.setAttribute('aria-label', `Transcript for ${note.filename}`);
|
||||
const save = document.createElement('button');
|
||||
save.className = 'save-transcript';
|
||||
save.type = 'button';
|
||||
save.textContent = 'SAVE TRANSCRIPT';
|
||||
save.addEventListener('click', async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}/transcript`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Save failed');
|
||||
save.textContent = 'SAVED';
|
||||
await loadArchive(elements['search-input'].value.trim());
|
||||
} catch {
|
||||
save.textContent = 'RETRY SAVE';
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
const status = document.createElement('div');
|
||||
status.className = 'archive-status';
|
||||
status.textContent = note.status.replaceAll('_', ' ').toUpperCase();
|
||||
body.append(name, transcript, save, status);
|
||||
item.append(date, body);
|
||||
elements['archive-list'].append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArchive(query = '') {
|
||||
if (!navigator.onLine) {
|
||||
renderArchive([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL('/api/notes', window.location.origin);
|
||||
if (query) url.searchParams.set('q', query);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Archive unavailable');
|
||||
renderArchive(await response.json());
|
||||
} catch {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = false;
|
||||
elements['archive-empty'].textContent = 'Could not reach the archive. Pending recordings remain on this device.';
|
||||
}
|
||||
}
|
||||
|
||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||
elements['sync-button'].addEventListener('click', syncQueue);
|
||||
elements['search-input'].addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => loadArchive(elements['search-input'].value.trim()), 250);
|
||||
});
|
||||
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
|
||||
window.addEventListener('offline', updateNetwork);
|
||||
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); });
|
||||
|
||||
elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase();
|
||||
updateNetwork();
|
||||
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
||||
loadArchive();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
Reference in New Issue
Block a user