Add audio recording workflow to notes workspace
This commit is contained in:
@@ -176,6 +176,15 @@
|
|||||||
<input id="attachmentsInput" class="hidden" type="file" multiple />
|
<input id="attachmentsInput" class="hidden" type="file" multiple />
|
||||||
<div id="pendingAttachmentsList" class="mt-2 space-y-1 text-xs text-slate-300"></div>
|
<div id="pendingAttachmentsList" class="mt-2 space-y-1 text-xs text-slate-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button id="startRecordBtn" class="btn btn-secondary" type="button">Start Recording</button>
|
||||||
|
<button id="stopRecordBtn" class="btn btn-secondary" type="button" disabled>Stop</button>
|
||||||
|
<button id="clearRecordBtn" class="btn btn-secondary" type="button">Clear Last Clip</button>
|
||||||
|
<span id="recordStatus" class="text-xs text-slate-400">Ready</span>
|
||||||
|
</div>
|
||||||
|
<audio id="recordPreview" class="mt-2 hidden w-full" controls preload="metadata"></audio>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
|
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
|
||||||
@@ -242,11 +251,21 @@
|
|||||||
const pendingAttachmentsList = document.getElementById('pendingAttachmentsList');
|
const pendingAttachmentsList = document.getElementById('pendingAttachmentsList');
|
||||||
const selectedAttachmentsStatus = document.getElementById('selectedAttachmentsStatus');
|
const selectedAttachmentsStatus = document.getElementById('selectedAttachmentsStatus');
|
||||||
const selectedAttachmentsList = document.getElementById('selectedAttachmentsList');
|
const selectedAttachmentsList = document.getElementById('selectedAttachmentsList');
|
||||||
|
const recordStatus = document.getElementById('recordStatus');
|
||||||
|
const recordPreview = document.getElementById('recordPreview');
|
||||||
|
const startRecordBtn = document.getElementById('startRecordBtn');
|
||||||
|
const stopRecordBtn = document.getElementById('stopRecordBtn');
|
||||||
|
const clearRecordBtn = document.getElementById('clearRecordBtn');
|
||||||
|
|
||||||
const saveBtn = document.getElementById('saveBtn');
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
const hideBtn = document.getElementById('hideBtn');
|
const hideBtn = document.getElementById('hideBtn');
|
||||||
const deleteBtn = document.getElementById('deleteBtn');
|
const deleteBtn = document.getElementById('deleteBtn');
|
||||||
|
|
||||||
|
let recorderStream = null;
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let recordChunks = [];
|
||||||
|
let lastRecordedFile = null;
|
||||||
|
|
||||||
function writeOutput(value) {
|
function writeOutput(value) {
|
||||||
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
@@ -385,6 +404,101 @@
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureRecorderStream() {
|
||||||
|
if (recorderStream) return recorderStream;
|
||||||
|
recorderStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return recorderStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecorderTracks() {
|
||||||
|
if (recorderStream) {
|
||||||
|
recorderStream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
recorderStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudioRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') return;
|
||||||
|
|
||||||
|
const stream = await ensureRecorderStream();
|
||||||
|
recordChunks = [];
|
||||||
|
mediaRecorder = new MediaRecorder(stream);
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
recordChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
startRecordBtn.disabled = true;
|
||||||
|
stopRecordBtn.disabled = false;
|
||||||
|
recordStatus.textContent = 'Recording…';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAudioRecording() {
|
||||||
|
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||||
|
mediaRecorder.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
|
||||||
|
const blob = new Blob(recordChunks, { type: mimeType });
|
||||||
|
stopRecorderTracks();
|
||||||
|
mediaRecorder = null;
|
||||||
|
startRecordBtn.disabled = false;
|
||||||
|
stopRecordBtn.disabled = true;
|
||||||
|
|
||||||
|
if (!blob.size) {
|
||||||
|
recordStatus.textContent = 'No audio captured.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = mimeType.includes('ogg') ? 'ogg' : mimeType.includes('mp4') ? 'm4a' : 'webm';
|
||||||
|
const fileName = `note-audio-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
||||||
|
const file = new File([blob], fileName, { type: mimeType });
|
||||||
|
pendingAttachments.push(file);
|
||||||
|
lastRecordedFile = file;
|
||||||
|
renderPendingAttachments();
|
||||||
|
|
||||||
|
if (recordPreview.src) URL.revokeObjectURL(recordPreview.src);
|
||||||
|
recordPreview.src = URL.createObjectURL(blob);
|
||||||
|
recordPreview.classList.remove('hidden');
|
||||||
|
recordStatus.textContent = `Recorded: ${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRecordedAudio() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
await stopAudioRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordPreview.src) {
|
||||||
|
URL.revokeObjectURL(recordPreview.src);
|
||||||
|
}
|
||||||
|
recordPreview.src = '';
|
||||||
|
recordPreview.classList.add('hidden');
|
||||||
|
|
||||||
|
if (lastRecordedFile) {
|
||||||
|
pendingAttachments = pendingAttachments.filter((f) => !(f.name === lastRecordedFile.name && f.size === lastRecordedFile.size));
|
||||||
|
renderPendingAttachments();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRecordedFile = null;
|
||||||
|
recordStatus.textContent = 'Ready';
|
||||||
|
stopRecorderTracks();
|
||||||
|
mediaRecorder = null;
|
||||||
|
startRecordBtn.disabled = false;
|
||||||
|
stopRecordBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAttachmentsForNote(noteId) {
|
async function fetchAttachmentsForNote(noteId) {
|
||||||
try {
|
try {
|
||||||
const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({
|
const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({
|
||||||
@@ -624,6 +738,13 @@
|
|||||||
shareInput.value = '';
|
shareInput.value = '';
|
||||||
pendingAttachments = [];
|
pendingAttachments = [];
|
||||||
renderPendingAttachments();
|
renderPendingAttachments();
|
||||||
|
if (recordPreview.src) {
|
||||||
|
URL.revokeObjectURL(recordPreview.src);
|
||||||
|
}
|
||||||
|
recordPreview.src = '';
|
||||||
|
recordPreview.classList.add('hidden');
|
||||||
|
recordStatus.textContent = 'Ready';
|
||||||
|
lastRecordedFile = null;
|
||||||
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
|
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
|
||||||
selectedAttachmentsList.innerHTML = '';
|
selectedAttachmentsList.innerHTML = '';
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
@@ -641,6 +762,13 @@
|
|||||||
shareInput.value = normalizeShares(note.shared_with).join(', ');
|
shareInput.value = normalizeShares(note.shared_with).join(', ');
|
||||||
pendingAttachments = [];
|
pendingAttachments = [];
|
||||||
renderPendingAttachments();
|
renderPendingAttachments();
|
||||||
|
if (recordPreview.src) {
|
||||||
|
URL.revokeObjectURL(recordPreview.src);
|
||||||
|
}
|
||||||
|
recordPreview.src = '';
|
||||||
|
recordPreview.classList.add('hidden');
|
||||||
|
recordStatus.textContent = 'Ready';
|
||||||
|
lastRecordedFile = null;
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
hideBtn.disabled = false;
|
hideBtn.disabled = false;
|
||||||
deleteBtn.disabled = false;
|
deleteBtn.disabled = false;
|
||||||
@@ -841,6 +969,30 @@
|
|||||||
renderPendingAttachments();
|
renderPendingAttachments();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startRecordBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await startAudioRecording();
|
||||||
|
} catch (error) {
|
||||||
|
recordStatus.textContent = `Recorder error: ${error?.message || String(error)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stopRecordBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await stopAudioRecording();
|
||||||
|
} catch (error) {
|
||||||
|
recordStatus.textContent = `Stop failed: ${error?.message || String(error)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearRecordBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await clearRecordedAudio();
|
||||||
|
} catch (error) {
|
||||||
|
recordStatus.textContent = `Clear failed: ${error?.message || String(error)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('clearBtn').addEventListener('click', () => {
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||||
clearForm();
|
clearForm();
|
||||||
renderList();
|
renderList();
|
||||||
|
|||||||
Reference in New Issue
Block a user