feat: add voice note recorder to browser extension
Adds voice recording popup with keyboard shortcut (Alt+Shift+V), microphone access, and Voice Note button in the extension popup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
17f2d49f12
commit
3d77eae16b
|
|
@ -291,6 +291,20 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Keyboard shortcut handler ---
|
||||||
|
|
||||||
|
chrome.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'open-voice-recorder') {
|
||||||
|
chrome.windows.create({
|
||||||
|
url: chrome.runtime.getURL('voice.html'),
|
||||||
|
type: 'popup',
|
||||||
|
width: 380,
|
||||||
|
height: 520,
|
||||||
|
focused: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Message Handler (from popup) ---
|
// --- Message Handler (from popup) ---
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "rNotes Web Clipper",
|
"name": "rNotes Web Clipper & Voice",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Clip pages, text, links, and images to rNotes.online",
|
"description": "Clip pages, text, links, and images to rNotes.online. Record voice notes with transcription.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"contextMenus",
|
"contextMenus",
|
||||||
"storage",
|
"storage",
|
||||||
"notifications"
|
"notifications",
|
||||||
|
"offscreen"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://rnotes.online/*",
|
"https://rnotes.online/*",
|
||||||
|
|
@ -33,5 +34,14 @@
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
"page": "options.html",
|
"page": "options.html",
|
||||||
"open_in_tab": false
|
"open_in_tab": false
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"open-voice-recorder": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+V",
|
||||||
|
"mac": "Command+Shift+V"
|
||||||
|
},
|
||||||
|
"description": "Open rVoice recorder"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@
|
||||||
color: #e5e5e5;
|
color: #e5e5e5;
|
||||||
border: 1px solid #404040;
|
border: 1px solid #404040;
|
||||||
}
|
}
|
||||||
|
.btn-voice {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #991b1b;
|
||||||
|
}
|
||||||
|
.btn-voice svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-unlock {
|
.btn-unlock {
|
||||||
background: #172554;
|
background: #172554;
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
|
|
@ -220,6 +229,18 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-voice" id="voiceBtn" disabled>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
Voice Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-unlock" id="unlockBtn" disabled>
|
<button class="btn-unlock" id="unlockBtn" disabled>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ async function init() {
|
||||||
// Enable buttons
|
// Enable buttons
|
||||||
document.getElementById('clipPageBtn').disabled = false;
|
document.getElementById('clipPageBtn').disabled = false;
|
||||||
document.getElementById('unlockBtn').disabled = false;
|
document.getElementById('unlockBtn').disabled = false;
|
||||||
|
document.getElementById('voiceBtn').disabled = false;
|
||||||
|
|
||||||
// Load notebooks
|
// Load notebooks
|
||||||
await populateNotebooks();
|
await populateNotebooks();
|
||||||
|
|
@ -299,6 +300,19 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('voiceBtn').addEventListener('click', () => {
|
||||||
|
// Open voice recorder in a small popup window
|
||||||
|
chrome.windows.create({
|
||||||
|
url: chrome.runtime.getURL('voice.html'),
|
||||||
|
type: 'popup',
|
||||||
|
width: 380,
|
||||||
|
height: 520,
|
||||||
|
focused: true,
|
||||||
|
});
|
||||||
|
// Close the current popup
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('optionsLink').addEventListener('click', (e) => {
|
document.getElementById('optionsLink').addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chrome.runtime.openOptionsPage();
|
chrome.runtime.openOptionsPage();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
width: 360px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #171717;
|
||||||
|
border-bottom: 1px solid #262626;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.header .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.header .brand-sub {
|
||||||
|
color: #a3a3a3;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.header .close-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #737373;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.header .close-btn:hover {
|
||||||
|
color: #e5e5e5;
|
||||||
|
background: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-warning {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #451a03;
|
||||||
|
border-bottom: 1px solid #78350f;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder {
|
||||||
|
padding: 20px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Record button */
|
||||||
|
.rec-btn {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #404040;
|
||||||
|
background: #171717;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.rec-btn:hover {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
.rec-btn .inner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.rec-btn.recording {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
.rec-btn.recording .inner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.rec-btn.recording::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(239, 68, 68, 0.3);
|
||||||
|
animation: pulse-ring 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% { transform: scale(1); opacity: 1; }
|
||||||
|
100% { transform: scale(1.15); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
font-size: 28px;
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e5e5;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.timer.recording {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-label.idle { color: #737373; }
|
||||||
|
.status-label.recording { color: #ef4444; }
|
||||||
|
.status-label.processing { color: #f59e0b; }
|
||||||
|
.status-label.done { color: #4ade80; }
|
||||||
|
|
||||||
|
/* Transcript area */
|
||||||
|
.transcript-area {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.transcript-area.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.transcript-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #737373;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.transcript-text {
|
||||||
|
background: #171717;
|
||||||
|
border: 1px solid #262626;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #d4d4d4;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.transcript-text.editable {
|
||||||
|
outline: none;
|
||||||
|
border-color: #404040;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.transcript-text.editable:focus {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
.transcript-text .placeholder {
|
||||||
|
color: #525252;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls row */
|
||||||
|
.controls {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 14px 10px;
|
||||||
|
}
|
||||||
|
.controls select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #171717;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.controls select:focus {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
.controls label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #737373;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.actions button:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.actions button:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #0a0a0a;
|
||||||
|
}
|
||||||
|
.btn-discard {
|
||||||
|
background: #262626;
|
||||||
|
color: #a3a3a3;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
background: #172554;
|
||||||
|
color: #93c5fd;
|
||||||
|
border: 1px solid #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status bar */
|
||||||
|
.status-bar {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-top: 1px solid #262626;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #525252;
|
||||||
|
text-align: center;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status-bar.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.status-bar.success { color: #4ade80; background: #052e16; border-top-color: #166534; }
|
||||||
|
.status-bar.error { color: #fca5a5; background: #450a0a; border-top-color: #991b1b; }
|
||||||
|
.status-bar.loading { color: #93c5fd; background: #172554; border-top-color: #1e40af; }
|
||||||
|
|
||||||
|
/* Audio preview */
|
||||||
|
.audio-preview {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 14px 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.audio-preview.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.audio-preview audio {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard hint */
|
||||||
|
.kbd-hint {
|
||||||
|
padding: 4px 14px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #404040;
|
||||||
|
}
|
||||||
|
.kbd-hint kbd {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<span>
|
||||||
|
<span class="brand">rVoice</span>
|
||||||
|
<span class="brand-sub">voice notes</span>
|
||||||
|
</span>
|
||||||
|
<button class="close-btn" id="closeBtn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="authWarning" class="auth-warning" style="display: none;">
|
||||||
|
Sign in via rNotes Clipper settings first.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recorder">
|
||||||
|
<div class="status-label idle" id="statusLabel">Ready</div>
|
||||||
|
<button class="rec-btn" id="recBtn" title="Start recording">
|
||||||
|
<div class="inner"></div>
|
||||||
|
</button>
|
||||||
|
<div class="timer" id="timer">00:00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-preview" id="audioPreview">
|
||||||
|
<audio controls id="audioPlayer"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="transcript-area" id="transcriptArea">
|
||||||
|
<div class="transcript-label">Transcript</div>
|
||||||
|
<div class="transcript-text editable" id="transcriptText" contenteditable="true">
|
||||||
|
<span class="placeholder">Transcribing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" id="notebookControls">
|
||||||
|
<label for="notebook">Save to notebook</label>
|
||||||
|
<select id="notebook">
|
||||||
|
<option value="">Default notebook</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" id="postActions" style="display: none;">
|
||||||
|
<button class="btn-discard" id="discardBtn">Discard</button>
|
||||||
|
<button class="btn-copy" id="copyBtn" title="Copy transcript">Copy</button>
|
||||||
|
<button class="btn-save" id="saveBtn">Save to rNotes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div class="kbd-hint">
|
||||||
|
<kbd>Space</kbd> to record · <kbd>Esc</kbd> to close
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="voice.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
const DEFAULT_HOST = 'https://rnotes.online';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let state = 'idle'; // idle | recording | processing | done
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let audioChunks = [];
|
||||||
|
let timerInterval = null;
|
||||||
|
let startTime = 0;
|
||||||
|
let audioBlob = null;
|
||||||
|
let audioUrl = null;
|
||||||
|
let transcript = '';
|
||||||
|
let uploadedFileUrl = '';
|
||||||
|
let uploadedMimeType = '';
|
||||||
|
let uploadedFileSize = 0;
|
||||||
|
let duration = 0;
|
||||||
|
|
||||||
|
// --- DOM refs ---
|
||||||
|
const recBtn = document.getElementById('recBtn');
|
||||||
|
const timerEl = document.getElementById('timer');
|
||||||
|
const statusLabel = document.getElementById('statusLabel');
|
||||||
|
const transcriptArea = document.getElementById('transcriptArea');
|
||||||
|
const transcriptText = document.getElementById('transcriptText');
|
||||||
|
const audioPreview = document.getElementById('audioPreview');
|
||||||
|
const audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
const notebookSelect = document.getElementById('notebook');
|
||||||
|
const postActions = document.getElementById('postActions');
|
||||||
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
|
const discardBtn = document.getElementById('discardBtn');
|
||||||
|
const copyBtn = document.getElementById('copyBtn');
|
||||||
|
const statusBar = document.getElementById('statusBar');
|
||||||
|
const authWarning = document.getElementById('authWarning');
|
||||||
|
const closeBtn = document.getElementById('closeBtn');
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
async function getSettings() {
|
||||||
|
const result = await chrome.storage.sync.get(['rnotesHost']);
|
||||||
|
return { host: result.rnotesHost || DEFAULT_HOST };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
const result = await chrome.storage.local.get(['encryptid_token']);
|
||||||
|
return result.encryptid_token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeToken(token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
|
||||||
|
return payload;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatusLabel(text, cls) {
|
||||||
|
statusLabel.textContent = text;
|
||||||
|
statusLabel.className = `status-label ${cls}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatusBar(message, type) {
|
||||||
|
statusBar.textContent = message;
|
||||||
|
statusBar.className = `status-bar visible ${type}`;
|
||||||
|
if (type === 'success') {
|
||||||
|
setTimeout(() => { statusBar.className = 'status-bar'; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notebook loader ---
|
||||||
|
|
||||||
|
async function loadNotebooks() {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return;
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${settings.host}/api/notebooks`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const notebooks = await res.json();
|
||||||
|
|
||||||
|
for (const nb of notebooks) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = nb.id;
|
||||||
|
opt.textContent = nb.title;
|
||||||
|
notebookSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore last used
|
||||||
|
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
|
||||||
|
if (lastNotebookId) notebookSelect.value = lastNotebookId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load notebooks:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notebookSelect.addEventListener('change', (e) => {
|
||||||
|
chrome.storage.local.set({ lastNotebookId: e.target.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Recording ---
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: 'audio/webm';
|
||||||
|
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) audioChunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start(1000);
|
||||||
|
startTime = Date.now();
|
||||||
|
state = 'recording';
|
||||||
|
|
||||||
|
// UI updates
|
||||||
|
recBtn.classList.add('recording');
|
||||||
|
timerEl.classList.add('recording');
|
||||||
|
setStatusLabel('Recording', 'recording');
|
||||||
|
postActions.style.display = 'none';
|
||||||
|
audioPreview.classList.remove('visible');
|
||||||
|
transcriptArea.classList.remove('visible');
|
||||||
|
statusBar.className = 'status-bar';
|
||||||
|
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
timerEl.textContent = formatTime(elapsed);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showStatusBar(err.message || 'Microphone access denied', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||||
|
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
duration = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
state = 'processing';
|
||||||
|
recBtn.classList.remove('recording');
|
||||||
|
timerEl.classList.remove('recording');
|
||||||
|
setStatusLabel('Processing...', 'processing');
|
||||||
|
|
||||||
|
// Stop recorder and collect blob
|
||||||
|
audioBlob = await new Promise((resolve) => {
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||||
|
resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
|
||||||
|
};
|
||||||
|
mediaRecorder.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show audio preview
|
||||||
|
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||||
|
audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
audioPlayer.src = audioUrl;
|
||||||
|
audioPreview.classList.add('visible');
|
||||||
|
|
||||||
|
// Show transcript area with placeholder
|
||||||
|
transcriptArea.classList.add('visible');
|
||||||
|
transcriptText.innerHTML = '<span class="placeholder">Transcribing...</span>';
|
||||||
|
|
||||||
|
// Upload audio file
|
||||||
|
const token = await getToken();
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
showStatusBar('Uploading recording...', 'loading');
|
||||||
|
|
||||||
|
const uploadForm = new FormData();
|
||||||
|
uploadForm.append('file', audioBlob, 'voice-note.webm');
|
||||||
|
|
||||||
|
const uploadRes = await fetch(`${settings.host}/api/uploads`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: uploadForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) throw new Error('Upload failed');
|
||||||
|
|
||||||
|
const uploadResult = await uploadRes.json();
|
||||||
|
uploadedFileUrl = uploadResult.url;
|
||||||
|
uploadedMimeType = uploadResult.mimeType;
|
||||||
|
uploadedFileSize = uploadResult.size;
|
||||||
|
|
||||||
|
// Transcribe via batch API
|
||||||
|
showStatusBar('Transcribing...', 'loading');
|
||||||
|
|
||||||
|
const transcribeForm = new FormData();
|
||||||
|
transcribeForm.append('audio', audioBlob, 'voice-note.webm');
|
||||||
|
|
||||||
|
const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: transcribeForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcribeRes.ok) {
|
||||||
|
const transcribeResult = await transcribeRes.json();
|
||||||
|
transcript = transcribeResult.text || '';
|
||||||
|
} else {
|
||||||
|
transcript = '';
|
||||||
|
console.warn('Transcription failed, saving without transcript');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show transcript (editable)
|
||||||
|
if (transcript) {
|
||||||
|
transcriptText.textContent = transcript;
|
||||||
|
} else {
|
||||||
|
transcriptText.innerHTML = '<span class="placeholder">No transcript available - you can type one here</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
state = 'done';
|
||||||
|
setStatusLabel('Done', 'done');
|
||||||
|
postActions.style.display = 'flex';
|
||||||
|
statusBar.className = 'status-bar';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showStatusBar(`Error: ${err.message}`, 'error');
|
||||||
|
state = 'done';
|
||||||
|
setStatusLabel('Error', 'idle');
|
||||||
|
postActions.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording() {
|
||||||
|
if (state === 'idle' || state === 'done') {
|
||||||
|
startRecording();
|
||||||
|
} else if (state === 'recording') {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
// Ignore clicks while processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save to rNotes ---
|
||||||
|
|
||||||
|
async function saveToRNotes() {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
showStatusBar('Saving to rNotes...', 'loading');
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Get current transcript text (user may have edited it)
|
||||||
|
const editedTranscript = transcriptText.textContent.trim();
|
||||||
|
const isPlaceholder = transcriptText.querySelector('.placeholder') !== null;
|
||||||
|
const finalTranscript = isPlaceholder ? '' : editedTranscript;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = now.toLocaleString('en-US', {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
title: `Voice note - ${timeStr}`,
|
||||||
|
content: finalTranscript
|
||||||
|
? `<p>${finalTranscript.replace(/\n/g, '</p><p>')}</p>`
|
||||||
|
: '<p><em>Voice recording (no transcript)</em></p>',
|
||||||
|
type: 'AUDIO',
|
||||||
|
mimeType: uploadedMimeType || 'audio/webm',
|
||||||
|
fileUrl: uploadedFileUrl,
|
||||||
|
fileSize: uploadedFileSize,
|
||||||
|
duration: duration,
|
||||||
|
tags: ['voice'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const notebookId = notebookSelect.value;
|
||||||
|
if (notebookId) body.notebookId = notebookId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${settings.host}/api/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatusBar('Saved to rNotes!', 'success');
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'notify',
|
||||||
|
title: 'Voice Note Saved',
|
||||||
|
message: `${formatTime(duration)} recording saved to rNotes`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset after short delay
|
||||||
|
setTimeout(resetState, 1500);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showStatusBar(`Save failed: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Copy to clipboard ---
|
||||||
|
|
||||||
|
async function copyTranscript() {
|
||||||
|
const text = transcriptText.textContent.trim();
|
||||||
|
if (!text || transcriptText.querySelector('.placeholder')) {
|
||||||
|
showStatusBar('No transcript to copy', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
showStatusBar('Copied to clipboard', 'success');
|
||||||
|
} catch {
|
||||||
|
showStatusBar('Copy failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Discard ---
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
state = 'idle';
|
||||||
|
mediaRecorder = null;
|
||||||
|
audioChunks = [];
|
||||||
|
audioBlob = null;
|
||||||
|
transcript = '';
|
||||||
|
uploadedFileUrl = '';
|
||||||
|
uploadedMimeType = '';
|
||||||
|
uploadedFileSize = 0;
|
||||||
|
duration = 0;
|
||||||
|
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
audioUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerEl.textContent = '00:00';
|
||||||
|
timerEl.classList.remove('recording');
|
||||||
|
recBtn.classList.remove('recording');
|
||||||
|
setStatusLabel('Ready', 'idle');
|
||||||
|
postActions.style.display = 'none';
|
||||||
|
audioPreview.classList.remove('visible');
|
||||||
|
transcriptArea.classList.remove('visible');
|
||||||
|
statusBar.className = 'status-bar';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyboard shortcuts ---
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Space bar: toggle recording (unless editing transcript)
|
||||||
|
if (e.code === 'Space' && document.activeElement !== transcriptText) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleRecording();
|
||||||
|
}
|
||||||
|
// Escape: close window
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
// Ctrl+Enter: save (when in done state)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveToRNotes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear placeholder on focus
|
||||||
|
transcriptText.addEventListener('focus', () => {
|
||||||
|
const ph = transcriptText.querySelector('.placeholder');
|
||||||
|
if (ph) transcriptText.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Event listeners ---
|
||||||
|
|
||||||
|
recBtn.addEventListener('click', toggleRecording);
|
||||||
|
saveBtn.addEventListener('click', saveToRNotes);
|
||||||
|
discardBtn.addEventListener('click', resetState);
|
||||||
|
copyBtn.addEventListener('click', copyTranscript);
|
||||||
|
closeBtn.addEventListener('click', () => window.close());
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const token = await getToken();
|
||||||
|
const claims = token ? decodeToken(token) : null;
|
||||||
|
|
||||||
|
if (!claims) {
|
||||||
|
authWarning.style.display = 'block';
|
||||||
|
recBtn.style.opacity = '0.3';
|
||||||
|
recBtn.style.pointerEvents = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authWarning.style.display = 'none';
|
||||||
|
await loadNotebooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
Loading…
Reference in New Issue