Skip to content

Example for a Browser Extension #53

@kyr0

Description

@kyr0

We'd like to implement an example of a browser extension compatible with:

  • Chromium-based Browsers (Chrome, Edge, Brave, ...)
  • Firefox (Gecko-based)
  • Safari (KHTML/Webkit based)

The extension should be compatible with Manifest V3 only. See: https://developer.chrome.com/docs/extensions/develop/migrate

We should support:

  • Background scripts - with long-lived support (living in worker scope, not being intercepted by browser/paused) - this is relevant for long-lived command & control extensions (automating website use via code execution via content scripts) - if the background script lives for an infinite time, it can pull tasks from a defuss-rpc server listening on a server - effectively implementing "Agentic Browser MCP".
  • IPC Message passing with BIG files (using copy-free passing) - this is to be able to move AI model files between execution contexts (allows for embedding models to run in-browser / background mutli-threaded).
  • Classic communication between background script and main world script. Let's also make it possible to use this via defuss-rpc as a transport layer (we need to extend defuss-rpc with transport layer abstractions for this; more transport layers will be WebSocket, WebRPC data channel later...).
  • Content script injection (injecting code into an active tab, returning result, passing back to background script) - with the script injection: Rendering a FAB (floating action button) - clicking it opens a modal using defuss-shadcn (this is to prepare for defuss developer tools).
  • Rendering a UI using defuss in Developer Tools as a tab (this is to prepare for defuss native developer tools).
  • Rendering a toolbar button and on click showing a defuss-rendered popup/overlay. This should render the background script logs as a demo example. The idea is, that using this, the user can observe and control agentic activity.

Over the years, I have already implemented many of the above - but we need to bring this together into one, defuss-native solution.

Code references:


Orchestrator code (for connecting to a local command and control server):

// Orchestrator for handling AI jobs via WebSocket
console.log('Orchestrator: Loaded');

const WS_URL = 'ws://localhost:31337';
let socket = null;
let reconnectInterval = 1000;

function connect() {
    console.log('Orchestrator: Connecting to', WS_URL);
    socket = new WebSocket(WS_URL);

    socket.onopen = () => {
        console.log('Orchestrator: Connected');
        reconnectInterval = 1000; // Reset backoff
    };

    socket.onmessage = async (event) => {
        try {
            const msg = JSON.parse(event.data);
            if (msg.type === 'new_job') {
                handleJob(msg.job);
            }
        } catch (e) {
            console.error('Orchestrator: Error parsing message', e);
        }
    };

    socket.onclose = () => {
        console.log('Orchestrator: Disconnected. Reconnecting in', reconnectInterval);
        setTimeout(connect, reconnectInterval);
        reconnectInterval = Math.min(reconnectInterval * 2, 30000);
    };

    socket.onerror = (err) => {
        console.error('Orchestrator: WebSocket error', err);
        socket.close();
    };
}

async function handleJob(job) {
    console.log('Orchestrator: Received job', job);
    const { id, model, messages, browserTools } = job;

    try {
        // 1. Execute Browser Tools
        const jobStartTime = Date.now();
        const toolStats = [];
        const toolResults = [];

        if (browserTools && Array.isArray(browserTools)) {
            log('Orchestrator', `Executing ${browserTools.length} browser tools...`);

            // Execute sequentially for now to avoid race conditions/focus stealing
            for (const tool of browserTools) {
                const toolStartTime = Date.now();
                try {
                    log('Orchestrator', `Running tool: ${tool.type}`);
                    const executor = Registry.getToolExecutor(tool.type);
                    const result = await executor.execute(tool);
                    toolResults.push({
                        type: tool.type,
                        params: tool,
                        result: result
                    });
                    toolStats.push({
                        type: tool.type,
                        duration_ms: Date.now() - toolStartTime,
                        status: 'success'
                    });
                } catch (e) {
                    console.error(`Orchestrator: Tool ${tool.type} failed`, e);
                    toolResults.push({
                        type: tool.type,
                        params: tool,
                        error: e.message
                    });
                    toolStats.push({
                        type: tool.type,
                        duration_ms: Date.now() - toolStartTime,
                        status: 'error',
                        error: e.message
                    });
                }
            }
        }

        // 2. NOP Mode Check (Implicit or Explicit)
        if (!model || model === 'nop') {
            log('Orchestrator', 'NOP mode detected. Returning tool results.');
            send({
                type: 'job_complete',
                jobId: id,
                result: JSON.stringify(toolResults),
                stats: {
                    total_duration_ms: Date.now() - jobStartTime,
                    tools: toolStats
                }
            });
            return;
        }

        // 3. Validate Messages
        if (!messages || !Array.isArray(messages)) {
            throw new Error("You need to provide the messages field when you want a model to execute it");
        }

        // 4. Context Injection
        let contextString = '';
        if (toolResults.length > 0) {
            contextString = toolResults.map((tr, i) => {
                if (tr.error) return `${i + 1}. ${tr.type} failed: ${tr.error}`;
                return `${i + 1}. ${tr.type} call for ${JSON.stringify(tr.params)} answered:\n${tr.result}`;
            }).join('\n\n---\n\n');
        }

        const injectedMessages = messages.map(msg => {
            if (typeof msg.content === 'string') {
                return { ...msg, content: msg.content.replace('${BROWSER_TOOL_CONTEXT}', contextString) };
            }
            return msg;
        });

        // 5. Execute Model
        log('Orchestrator', `Executing model: ${model}`);
        const modelStartTime = Date.now();
        const modelExecutor = Registry.getModelExecutor(model);
        const result = await modelExecutor.execute({ ...job, messages: injectedMessages });
        const modelDuration = Date.now() - modelStartTime;

        send({
            type: 'job_complete',
            jobId: id,
            result,
            stats: {
                total_duration_ms: Date.now() - jobStartTime,
                model_duration_ms: modelDuration,
                tools: toolStats
            }
        });

    } catch (error) {
        console.error('Orchestrator: Job failed', error);

        // Notify Keep-Alive tab
        notifyKeepAlive('Job Failed', `Model: ${model || 'nop'}. Error: ${error.message}`);

        // For now, just send error back
        send({ type: 'job_error', jobId: id, error: error.message });
    }
}

async function notifyKeepAlive(title, message) {
    const url = 'http://localhost:31337/keep-alive';
    const tabs = await chrome.tabs.query({ url });
    if (tabs.length > 0) {
        chrome.tabs.sendMessage(tabs[0].id, {
            action: 'show_notification',
            title,
            message
        });
        // Optional: Focus the keep-alive tab
        // chrome.tabs.update(tabs[0].id, { active: true });
    }
}

function send(msg) {
    if (socket && socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify(msg));
    }
}

// --- Helper Functions (Placeholder for now, will import from Registry) ---

// These functions are now handled by Registry.getToolExecutor and Registry.getModelExecutor
// and the logic is integrated directly into handleJob.
// The original helper functions are no longer needed in this file.

// async function executeBrowserTools(tools) { ... }
// function formatToolResults(results) { ... }
// function injectContext(messages, context) { ... }
// async function executeModel(modelName, messages) { ... }

// Start connection
connect();
ensureKeepAliveTab();

// Keep-alive listener
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'heartbeat') {
        console.log('Orchestrator: Heartbeat received');
        sendResponse({ status: 'alive' });
    } else if (request.action === 'log_entry') {
        const { tag, message, level, timestamp } = request;
        const source = sender.tab ? `tab-${sender.tab.id}` : 'extension';

        // Forward to server
        send({
            type: 'remote_log',
            log: {
                source,
                tag,
                message,
                level,
                timestamp
            }
        });
    }
});

// Helper for Orchestrator logging
function log(tag, ...args) {
    const message = args.map(a => (typeof a === 'object') ? JSON.stringify(a) : String(a)).join(' ');
    console.log(`[${tag}]`, ...args);
    send({
        type: 'remote_log',
        log: {
            source: 'orchestrator',
            tag,
            message,
            level: 'info',
            timestamp: Date.now()
        }
    });
}

async function ensureKeepAliveTab() {
    const url = 'http://localhost:31337/keep-alive';
    const tabs = await chrome.tabs.query({ url });

    if (tabs.length === 0) {
        console.log('Orchestrator: Opening keep-alive tab');
        await chrome.tabs.create({ url, active: false, pinned: true });
    } else {
        console.log('Orchestrator: Keep-alive tab already exists');
    }
}

Example code for "executors" chosen by orchestrator for work...:

// Background service worker for handling API requests
// This script manages communication between content scripts and external APIs
// Import configuration
importScripts('../config/config.js');
importScripts('../executors/registry.js');
importScripts('../executors/base.js');
importScripts('../executors/prompt-decorator.js');
importScripts('../executors/model/chatgpt.js');
importScripts('../executors/tool/google.js');
importScripts('../executors/tool/linkedin.js');
importScripts('../executors/tool/maps-google.js');
importScripts('../executors/tool/wise-iban.js');
importScripts('orchestrator.js');

console.log('Profile To minless: Background service worker loaded');

/**
 * Main message listener for handling requests from content scripts
 * Processes profile data and forwards it to APIs
 */
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  console.log('Background received message:', request.action);

  if (request.action === "sendToApi") {
    // Handle both profile data and activity data
    const dataToProcess = request.profileData || request.activityData;
    if (dataToProcess) {
      processApiRequest(dataToProcess, sendResponse);
    } else {
      sendResponse({
        success: false,
        message: 'No profile or activity data provided'
      });
    }
    return true; // Keep message channel open for async response
  }
});

/**
 * Processes profile data and sends it to the configured API
 * Handles test mode detection and error scenarios
 * @param {Object} profileData - The extracted LinkedIn profile data
 * @param {Function} sendResponse - Callback function to send response back to content script
 */
async function processApiRequest(profileData, sendResponse) {
  try {
    console.log('Background: Processing profile data for API:', profileData);

    // Use webhook URL from configuration
    const ApiUrl = CONFIG.API_URL;

    // Handle test mode - don't send actual webhook request
    if (profileData.list === "Test") {
      console.log('Background: Test mode detected - skipping API call');
      console.log('Test profile data:', profileData);
      sendResponse({
        success: true,
        message: 'Test mode: Profile data processed successfully without sending to Zapier'
      });
      return;
    }

    // Send profile data to API
    // Dont provide headers as CORS is not allowed
    const webhookResponse = await fetch(ApiUrl, {
      method: 'POST',
      body: JSON.stringify(profileData)
    });

    // Handle successful webhook response
    if (webhookResponse.ok) {
      console.log('Background: Profile data successfully sent to API');
      const responseData = await webhookResponse.json();
      console.log('Background: API response:', responseData);

      sendResponse({
        success: true,
        message: 'Profile data successfully sent to API'
      });
    } else {
      // Handle HTTP error responses
      console.error('Background: API request failed with status:', webhookResponse.status);
      sendResponse({
        success: false,
        message: `Failed to send data to API (HTTP ${webhookResponse.status})`
      });
    }
  } catch (error) {
    // Handle network errors and other exceptions
    console.error('Background: Error sending profile data to API:', error);
    sendResponse({
      success: false,
      message: 'Network error when sending data to API: ' + error.message
    });
  }
} 

BaseExecutor:

self.BaseExecutor = class BaseExecutor {
    constructor(domain) {
        this.domain = domain;
    }

    async execute(data) {
        throw new Error('Not implemented');
    }

    async createTab(url) {
        const targetUrl = url || `https://${this.domain}`;
        const tab = await chrome.tabs.create({ url: targetUrl });
        await this.waitForTabLoad(tab.id);
        return tab;
    }

    async closeTab(tabId) {
        try {
            await chrome.tabs.remove(tabId);
        } catch (e) {
            console.warn('Failed to close tab', tabId, e);
        }
    }

    async waitForTabLoad(tabId) {
        return new Promise(async (resolve) => {
            const tab = await chrome.tabs.get(tabId);
            if (tab.status === 'complete') {
                return resolve(tab);
            }

            const listener = (tid, changeInfo, tab) => {
                if (tid === tabId && changeInfo.status === 'complete') {
                    chrome.tabs.onUpdated.removeListener(listener);
                    resolve(tab);
                }
            };
            chrome.tabs.onUpdated.addListener(listener);
        });
    }

    async sendMessageToTab(tabId, action, data) {
        return new Promise((resolve, reject) => {
            chrome.tabs.sendMessage(tabId, { action, data }, (response) => {
                if (chrome.runtime.lastError) {
                    return reject(new Error(chrome.runtime.lastError.message));
                }
                if (response && response.success) {
                    resolve(response.result || response.data);
                } else {
                    reject(new Error(response ? response.error : 'Unknown error from content script'));
                }
            });
        });
    }

    // Helper to be used INSIDE the injected script
    static async waitForElement(selector, timeout = 10000) {
        return new Promise((resolve, reject) => {
            if (document.querySelector(selector)) {
                return resolve(document.querySelector(selector));
            }

            const observer = new MutationObserver(mutations => {
                if (document.querySelector(selector)) {
                    resolve(document.querySelector(selector));
                    observer.disconnect();
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            setTimeout(() => {
                observer.disconnect();
                reject(new Error(`Element ${selector} not found`));
            }, timeout);
        });
    }
}

Executor Tool registry:

// Registry for Executors
self.Registry = {
    models: new Map(),
    tools: new Map(),

    registerModel(name, executorClass) {
        console.log(`Registry: Registering model ${name}`);
        this.models.set(name, executorClass);
    },

    registerTool(name, cls) {
        console.log(`Registry: Registering tool ${name}`);
        this.tools.set(name, cls);
    },

    getModel(name) {
        const cls = this.models.get(name);
        console.log(`Registry: Getting model ${name} -> ${cls ? 'Found' : 'Not Found'}`);
        return cls;
    },

    getTool(name) {
        const cls = this.tools.get(name);
        console.log(`Registry: Getting tool ${name} -> ${cls ? 'Found' : 'Not Found'}`);
        return cls;
    },

    getModelExecutor(name) {
        const Cls = this.getModel(name);
        if (!Cls) throw new Error(`Model executor not found for: ${name}`);
        return new Cls();
    },

    getToolExecutor(name) {
        const Cls = this.getTool(name);
        if (!Cls) throw new Error(`Tool executor not found for: ${name}`);
        return new Cls();
    }
};

Google Search Executor:

self.GoogleExecutor = class GoogleExecutor extends self.BaseExecutor {
    constructor() {
        super('google.com');
    }

    async execute(toolData) {
        const query = toolData.query;
        const topK = toolData.topK || 3;
        const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;

        const tab = await this.createTab(searchUrl);

        try {
            return await this.sendMessageToTab(tab.id, 'EXECUTE_GOOGLE', { query, topK });
        } finally {
            await this.closeTab(tab.id);
        }
    }
}

Registry.registerTool('google.com', GoogleExecutor);

Actual Google Search automation impl (living in content script - injected!):

// Google Content Script
const log = (tag, ...args) => window.RemoteLogger ? window.RemoteLogger.log(tag, ...args) : console.log(`[${tag}]`, ...args);
const logError = (tag, ...args) => window.RemoteLogger ? window.RemoteLogger.error(tag, ...args) : console.error(`[${tag}]`, ...args);

log('Google', 'Content Script Loaded');

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'EXECUTE_GOOGLE') {
        log('Google', 'Executing request', request.data);
        executeGoogle(request.data, sendResponse);
        return true; // Async response
    }
});

async function executeGoogle(data, sendResponse) {
    try {
        const { topK } = data;

        // --- Wait for Content ---
        const waitForSelector = (selectors, timeout = 10000) => {
            return new Promise((resolve) => {
                const check = () => {
                    for (let s of selectors) {
                        const el = document.querySelector(s);
                        if (el) return resolve(el);
                    }
                };

                const observer = new MutationObserver(() => {
                    check();
                });

                observer.observe(document.body, { childList: true, subtree: true });

                // Initial check
                check();

                setTimeout(() => {
                    observer.disconnect();
                    resolve(null); // Timeout
                }, timeout);
            });
        };

        log('Google', 'Waiting for results...');
        // Wait for either answer box or results
        await waitForSelector(['[data-spe="true"]', '[data-subtree="aimc"]', '[data-rpos]'], 10000);
        log('Google', 'Results found (or timeout)');

        // --- Extraction ---
        let resultMarkdown = '';

        // 1. Try Answer Box / Knowledge Panel
        const answerBox = document.querySelector('[data-spe="true"]') || document.querySelector('[data-subtree="aimc"]');
        if (answerBox) {
            log('Google', 'Found Answer Box');
            resultMarkdown += "### Answer Box\n";
            resultMarkdown += window.htmlToMarkdown(answerBox);
            resultMarkdown += "\n\n---\n\n";
        }

        // 2. Top K Results
        const results = Array.from(document.querySelectorAll('[data-rpos]'));
        log('Google', `Found ${results.length} organic results`);

        const k = topK || 3;
        const topResults = results.slice(0, k);

        if (topResults.length > 0) {
            resultMarkdown += `### Top ${topResults.length} Results\n`;
            for (let i = 0; i < topResults.length; i++) {
                resultMarkdown += `\n#### Result ${i + 1}\n`;
                resultMarkdown += window.htmlToMarkdown(topResults[i]);
                resultMarkdown += "\n";
            }
        }

        if (!resultMarkdown) {
            log('Google', 'No markdown extracted');
            sendResponse({ success: true, result: "No results found." });
        } else {
            log('Google', 'Extraction complete');
            sendResponse({ success: true, result: resultMarkdown });
        }

    } catch (error) {
        logError('Google', 'Error:', error);
        sendResponse({ success: false, error: error.message });
    }
}

keep-alive script:

// Keep-alive content script
console.log('Keep-Alive: Content script loaded');

setInterval(() => {
    if (!chrome.runtime?.id) {
        console.log('Keep-Alive: Extension context invalidated. Reloading...');
        window.location.reload();
        return;
    }

    console.log('Keep-Alive: Sending heartbeat');
    try {
        chrome.runtime.sendMessage({ action: 'heartbeat' }, (response) => {
            if (chrome.runtime.lastError) {
                console.warn('Keep-Alive: Error sending heartbeat', chrome.runtime.lastError);
                if (chrome.runtime.lastError.message.includes('Extension context invalidated')) {
                    console.log('Keep-Alive: Reloading due to invalidation');
                    window.location.reload();
                }
            } else {
                console.log('Keep-Alive: Heartbeat acknowledged', response);
            }
        });
    } catch (e) {
        console.error('Keep-Alive: Exception sending heartbeat', e);
        if (e.message.includes('Extension context invalidated')) {
            window.location.reload();
        }
    }
}, 25000); // 25 seconds

// Request notification permission
if (Notification.permission !== 'granted') {
    Notification.requestPermission();
}

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'show_notification') {
        console.log('Keep-Alive: Showing notification', request);

        // Show Web Notification
        if (Notification.permission === 'granted') {
            new Notification(request.title, {
                body: request.message,
                icon: '/assets/icons/icon128.png' // Assuming this exists or use default
            });
        }

        // Also show in DOM
        const div = document.createElement('div');
        div.style.cssText = 'position: fixed; top: 10px; right: 10px; background: red; color: white; padding: 10px; z-index: 9999; border-radius: 5px;';
        div.innerText = `${request.title}: ${request.message}`;
        document.body.appendChild(div);
        setTimeout(() => div.remove(), 5000);
    }
});

Finally, some code for rendering toolbar icon and popup:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WhisperLiveKit</title>
    <link rel="stylesheet" href="popup.css" />
</head>

<body>
    <div class="header-container">
        <div class="settings-container">
            <div class="buttons-container">
                <button id="recordButton">
                    <div class="shape-container">
                        <div class="shape"></div>
                    </div>
                    <div class="recording-info">
                        <div class="wave-container">
                            <canvas id="waveCanvas"></canvas>
                        </div>
                        <div class="timer">00:00</div>
                    </div>
                </button>

                <button id="settingsToggle" class="settings-toggle" title="Show/hide settings">
                    <img src="icons/settings.svg" alt="Settings" />
                </button>
            </div>

            <div class="settings">
                <div class="field">
                    <label for="websocketInput">Websocket URL</label>
                    <input id="websocketInput" type="text" placeholder="ws://host:port/asr" />
                </div>

                <div class="field">
                    <label id="microphoneSelectLabel" for="microphoneSelect">Select Microphone</label>
                    <select id="microphoneSelect">
                        <option value="">Default Microphone</option>
                    </select>
                </div>

                <div class="theme-selector-container">
                    <div class="segmented" role="radiogroup" aria-label="Theme selector">
                        <input type="radio" id="theme-system" name="theme" value="system" />
                        <label for="theme-system" title="System">
                            <img src="icons/system_mode.svg" alt="" />
                            <span>System</span>
                        </label>

                        <input type="radio" id="theme-light" name="theme" value="light" />
                        <label for="theme-light" title="Light">
                            <img src="icons/light_mode.svg" alt="" />
                            <span>Light</span>
                        </label>

                        <input type="radio" id="theme-dark" name="theme" value="dark" />
                        <label for="theme-dark" title="Dark">
                            <img src="icons/dark_mode.svg" alt="" />
                            <span>Dark</span>
                            <script src="popup.js"></script>
</body>

</html>
const isExtension = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL;
if (isExtension) {
    document.documentElement.classList.add('is-extension');
}
const isWebContext = !isExtension;

let isRecording = false;
let websocket = null;
let orchestratorWs = null; // Connection to Orchestrator (31337)
let recorder = null;
let chunkDuration = 100;
let websocketUrl = "ws://localhost:31338/asr"; // Default to 31338
const orchestratorUrl = "ws://localhost:31337";
let userClosing = false;
let wakeLock = null;
let startTime = null;
let timerInterval = null;
let audioContext = null;
let analyser = null;
let microphone = null;
let workletNode = null;
let recorderWorker = null;
let waveCanvas = document.getElementById("waveCanvas");
let waveCtx = waveCanvas.getContext("2d");
let animationFrame = null;
let waitingForStop = false;
let lastReceivedData = null;
let lastSignature = null;
let availableMicrophones = [];
let selectedMicrophoneId = null;
let serverUseAudioWorklet = null;
let configReadyResolve;
const configReady = new Promise((r) => (configReadyResolve = r));
let outputAudioContext = null;
let audioSource = null;

waveCanvas.width = 60 * (window.devicePixelRatio || 1);
waveCanvas.height = 30 * (window.devicePixelRatio || 1);
waveCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);

const statusText = document.getElementById("status");
const recordButton = document.getElementById("recordButton");
const chunkSelector = document.getElementById("chunkSelector");
const websocketInput = document.getElementById("websocketInput");
const websocketDefaultSpan = document.getElementById("wsDefaultUrl");
const linesTranscriptDiv = document.getElementById("linesTranscript");
const timerElement = document.querySelector(".timer");
const themeRadios = document.querySelectorAll('input[name="theme"]');
const microphoneSelect = document.getElementById("microphoneSelect");

const settingsToggle = document.getElementById("settingsToggle");
const settingsDiv = document.querySelector(".settings");

const translationIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12px" viewBox="0 -960 960 960" width="12px" fill="#5f6368"><path d="m603-202-34 97q-4 11-14 18t-22 7q-20 0-32.5-16.5T496-133l152-402q5-11 15-18t22-7h30q12 0 22 7t15 18l152 403q8 19-4 35.5T868-80q-13 0-22.5-7T831-106l-34-96H603ZM362-401 188-228q-11 11-27.5 11.5T132-228q-11-11-11-28t11-28l174-174q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H80q-17 0-28.5-11.5T40-760q0-17 11.5-28.5T80-800h240v-40q0-17 11.5-28.5T360-880q17 0 28.5 11.5T400-840v40h240q17 0 28.5 11.5T680-760q0 17-11.5 28.5T640-720h-76q-21 72-63 148t-83 116l96 98-30 82-122-125Zm266 129h144l-72-204-72 204Z"/></svg>`
const silenceIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="vertical-align: text-bottom;" height="14px" viewBox="0 -960 960 960" width="14px" fill="#5f6368"><path d="M514-556 320-752q9-3 19-5.5t21-2.5q66 0 113 47t47 113q0 11-1.5 22t-4.5 22ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q26 0 49.5 2.5T456-392l-56-54q-9 3-19 4.5t-21 1.5q-66 0-113-47t-47-113q0-11 1.5-21t4.5-19L84-764q-11-11-11-28t11-28q12-12 28.5-12t27.5 12l675 685q11 11 11.5 27.5T816-80q-11 13-28 12.5T759-80L641-200h39q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Zm80 0h480v-32q0-14-4.5-19.5T580-266q-36-18-92.5-36T360-320q-71 0-127.5 18T140-266q-9 5-14.5 14t-5.5 20v32Zm240 0Zm560-400q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5Z"/></svg>`;
const languageIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12" viewBox="0 -960 960 960" width="12" fill="#5f6368"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>`
const speakerIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="16px" style="vertical-align: text-bottom;" viewBox="0 -960 960 960" width="16px" fill="#5f6368"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-240v-32q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v32q0 33-23.5 56.5T720-160H240q-33 0-56.5-23.5T160-240Zm80 0h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>`;

function getWaveStroke() {
    const styles = getComputedStyle(document.documentElement);
    const v = styles.getPropertyValue("--wave-stroke").trim();
    return v || "#000";
}

let waveStroke = getWaveStroke();
function updateWaveStroke() {
    waveStroke = getWaveStroke();
}

function applyTheme(pref) {
    if (pref === "light") {
        document.documentElement.setAttribute("data-theme", "light");
    } else if (pref === "dark") {
        document.documentElement.setAttribute("data-theme", "dark");
    } else {
        document.documentElement.removeAttribute("data-theme");
    }
    updateWaveStroke();
}

// Persisted theme preference
const savedThemePref = localStorage.getItem("themePreference") || "system";
applyTheme(savedThemePref);
if (themeRadios.length) {
    themeRadios.forEach((r) => {
        r.checked = r.value === savedThemePref;
        r.addEventListener("change", () => {
            if (r.checked) {
                localStorage.setItem("themePreference", r.value);
                applyTheme(r.value);
            }
        });
    });
}

// React to OS theme changes when in "system" mode
const darkMq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
const handleOsThemeChange = () => {
    const pref = localStorage.getItem("themePreference") || "system";
    if (pref === "system") updateWaveStroke();
};
if (darkMq && darkMq.addEventListener) {
    darkMq.addEventListener("change", handleOsThemeChange);
} else if (darkMq && darkMq.addListener) {
    // deprecated, but included for Safari compatibility
    darkMq.addListener(handleOsThemeChange);
}

const requestPermissionBtn = document.getElementById("requestPermissionBtn");

async function enumerateMicrophones() {
    try {
        // Check if we already have permission
        const permissions = await navigator.permissions.query({ name: 'microphone' });
        if (permissions.state === 'denied') {
            statusText.textContent = "Microphone permission denied. Please enable it in browser settings.";
            return;
        }

        // If 'prompt' or 'granted', try to get stream to enumerate labels
        // Note: getUserMedia might fail if no user gesture, but usually works in popup if previously granted
        // or if it's the first time and we are in a valid context.
        // However, to be safe, if it fails, we show the button.

        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        stream.getTracks().forEach(track => track.stop());

        const devices = await navigator.mediaDevices.enumerateDevices();
        availableMicrophones = devices.filter(device => device.kind === 'audioinput');

        populateMicrophoneSelect();
        console.log(`Found ${availableMicrophones.length} microphone(s)`);

        if (requestPermissionBtn) requestPermissionBtn.style.display = 'none';
        statusText.textContent = "Ready to record.";

    } catch (error) {
        console.error('Error enumerating microphones:', error);
        statusText.textContent = "Microphone access required.";
        if (requestPermissionBtn) {
            requestPermissionBtn.style.display = 'inline-block';
            requestPermissionBtn.onclick = async () => {
                try {
                    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
                    stream.getTracks().forEach(track => track.stop());
                    await enumerateMicrophones();
                } catch (e) {
                    console.error("Permission request failed:", e);
                    statusText.textContent = "Permission denied or failed.";
                }
            };
        }
    }
}

function populateMicrophoneSelect() {
    if (!microphoneSelect) return;

    microphoneSelect.innerHTML = '<option value="">Default Microphone</option>';

    availableMicrophones.forEach((device, index) => {
        const option = document.createElement('option');
        option.value = device.deviceId;
        option.textContent = device.label || `Microphone ${index + 1}`;
        microphoneSelect.appendChild(option);
    });

    const savedMicId = localStorage.getItem('selectedMicrophone');
    if (savedMicId && availableMicrophones.some(mic => mic.deviceId === savedMicId)) {
        microphoneSelect.value = savedMicId;
        selectedMicrophoneId = savedMicId;
    }
}

function handleMicrophoneChange() {
    selectedMicrophoneId = microphoneSelect.value || null;
    localStorage.setItem('selectedMicrophone', selectedMicrophoneId || '');

    const selectedDevice = availableMicrophones.find(mic => mic.deviceId === selectedMicrophoneId);
    const deviceName = selectedDevice ? selectedDevice.label : 'Default Microphone';

    console.log(`Selected microphone: ${deviceName}`);
    statusText.textContent = `Microphone changed to: ${deviceName}`;

    if (isRecording) {
        statusText.textContent = "Switching microphone... Please wait.";
        stopRecording().then(() => {
            setTimeout(() => {
                toggleRecording();
            }, 1000);
        });
    }
}

// Helpers
function fmt1(x) {
    const n = Number(x);
    return Number.isFinite(n) ? n.toFixed(1) : x;
}

// Populate default caption and input
if (websocketDefaultSpan) websocketDefaultSpan.textContent = websocketUrl;
websocketInput.value = websocketUrl;

// Optional chunk selector (guard for presence)
if (chunkSelector) {
    chunkSelector.addEventListener("change", () => {
        chunkDuration = parseInt(chunkSelector.value);
    });
}

// WebSocket input change handling
websocketInput.addEventListener("change", () => {
    const urlValue = websocketInput.value.trim();
    if (!urlValue.startsWith("ws://") && !urlValue.startsWith("wss://")) {
        statusText.textContent = "Invalid WebSocket URL (must start with ws:// or wss://)";
        return;
    }
    websocketUrl = urlValue;
    statusText.textContent = "WebSocket URL updated. Ready to connect.";
});

function setupWebSocket() {
    return new Promise((resolve, reject) => {
        try {
            websocket = new WebSocket(websocketUrl);
        } catch (error) {
            statusText.textContent = "Invalid WebSocket URL. Please check and try again.";
            reject(error);
            return;
        }

        websocket.onopen = () => {
            statusText.textContent = "Connected to ASR server.";
            resolve();
        };

        websocket.onclose = () => {
            if (userClosing) {
                if (waitingForStop) {
                    statusText.textContent = "Processing finalized or connection closed.";
                    if (lastReceivedData) {
                        renderLinesWithBuffer(
                            lastReceivedData.lines || [],
                            lastReceivedData.buffer_diarization || "",
                            lastReceivedData.buffer_transcription || "",
                            lastReceivedData.buffer_translation || "",
                            0,
                            0,
                            true
                        );
                    }
                }
            } else {
                statusText.textContent = "Disconnected from the WebSocket server. (Check logs if model is loading.)";
                if (isRecording) {
                    stopRecording();
                }
            }
            isRecording = false;
            waitingForStop = false;
            userClosing = false;
            lastReceivedData = null;
            websocket = null;
            updateUI();
        };

        websocket.onerror = () => {
            statusText.textContent = "Error connecting to WebSocket.";
            reject(new Error("Error connecting to WebSocket"));
        };

        websocket.onmessage = (event) => {
            const data = JSON.parse(event.data);

            // Forward to Orchestrator
            if (orchestratorWs && orchestratorWs.readyState === WebSocket.OPEN) {
                orchestratorWs.send(JSON.stringify({
                    type: 'transcription',
                    data: data
                }));
            }

            if (data.type === "config") {
                serverUseAudioWorklet = !!data.useAudioWorklet;
                statusText.textContent = serverUseAudioWorklet
                    ? "Connected. Using AudioWorklet (PCM)."
                    : "Connected. Using MediaRecorder (WebM).";
                if (configReadyResolve) configReadyResolve();
                return;
            }

            if (data.type === "ready_to_stop") {
                console.log("Ready to stop received, finalizing display and closing WebSocket.");
                waitingForStop = false;

                if (lastReceivedData) {
                    renderLinesWithBuffer(
                        lastReceivedData.lines || [],
                        lastReceivedData.buffer_diarization || "",
                        lastReceivedData.buffer_transcription || "",
                        lastReceivedData.buffer_translation || "",
                        0,
                        0,
                        true
                    );
                }
                statusText.textContent = "Finished processing audio! Ready to record again.";
                recordButton.disabled = false;

                if (websocket) {
                    websocket.close();
                }
                return;
            }

            lastReceivedData = data;

            const {
                lines = [],
                buffer_transcription = "",
                buffer_diarization = "",
                buffer_translation = "",
                remaining_time_transcription = 0,
                remaining_time_diarization = 0,
                status = "active_transcription",
            } = data;

            renderLinesWithBuffer(
                lines,
                buffer_diarization,
                buffer_transcription,
                buffer_translation,
                remaining_time_diarization,
                remaining_time_transcription,
                false,
                status
            );
        };
    });
}

function setupOrchestratorWebSocket() {
    try {
        orchestratorWs = new WebSocket(orchestratorUrl);

        orchestratorWs.onopen = () => {
            console.log("Connected to Orchestrator.");
        };

        orchestratorWs.onclose = () => {
            console.log("Disconnected from Orchestrator.");
            orchestratorWs = null;
        };

        orchestratorWs.onerror = (err) => {
            console.error("Orchestrator WS Error:", err);
        };

    } catch (e) {
        console.error("Failed to connect to Orchestrator:", e);
    }
}

function renderLinesWithBuffer(
    lines,
    buffer_diarization,
    buffer_transcription,
    buffer_translation,
    remaining_time_diarization,
    remaining_time_transcription,
    isFinalizing = false,
    current_status = "active_transcription"
) {
    if (current_status === "no_audio_detected") {
        linesTranscriptDiv.innerHTML =
            "<p style='text-align: center; color: var(--muted); margin-top: 20px;'><em>No audio detected...</em></p>";
        return;
    }

    const showLoading = !isFinalizing && (lines || []).some((it) => it.speaker == 0);
    const showTransLag = !isFinalizing && remaining_time_transcription > 0;
    const showDiaLag = !isFinalizing && !!buffer_diarization && remaining_time_diarization > 0;
    const signature = JSON.stringify({
        lines: (lines || []).map((it) => ({ speaker: it.speaker, text: it.text, start: it.start, end: it.end, detected_language: it.detected_language })),
        buffer_transcription: buffer_transcription || "",
        buffer_diarization: buffer_diarization || "",
        buffer_translation: buffer_translation,
        status: current_status,
        showLoading,
        showTransLag,
        showDiaLag,
        isFinalizing: !!isFinalizing,
    });
    if (lastSignature === signature) {
        const t = document.querySelector(".lag-transcription-value");
        if (t) t.textContent = fmt1(remaining_time_transcription);
        const d = document.querySelector(".lag-diarization-value");
        if (d) d.textContent = fmt1(remaining_time_diarization);
        const ld = document.querySelector(".loading-diarization-value");
        if (ld) ld.textContent = fmt1(remaining_time_diarization);
        return;
    }
    lastSignature = signature;

    const linesHtml = (lines || [])
        .map((item, idx) => {
            let timeInfo = "";
            if (item.start !== undefined && item.end !== undefined) {
                timeInfo = ` ${item.start} - ${item.end}`;
            }

            let speakerLabel = "";
            if (item.speaker === -2) {
                speakerLabel = `<span class="silence">${silenceIcon}<span id='timeInfo'>${timeInfo}</span></span>`;
            } else if (item.speaker == 0 && !isFinalizing) {
                speakerLabel = `<span class='loading'><span class="spinner"></span><span id='timeInfo'><span class="loading-diarization-value">${fmt1(
                    remaining_time_diarization
                )}</span> second(s) of audio are undergoing diarization</span></span>`;
            } else if (item.speaker !== 0) {
                const speakerNum = `<span class="speaker-badge">${item.speaker}</span>`;
                speakerLabel = `<span id="speaker">${speakerIcon}${speakerNum}<span id='timeInfo'>${timeInfo}</span></span>`;

                if (item.detected_language) {
                    speakerLabel += `<span class="label_language">${languageIcon}<span>${item.detected_language}</span></span>`;
                }
            }

            let currentLineText = item.text || "";

            if (idx === lines.length - 1) {
                if (!isFinalizing && item.speaker !== -2) {
                    speakerLabel += `<span class="label_transcription"><span class="spinner"></span>Transcription lag <span id='timeInfo'><span class="lag-transcription-value">${fmt1(
                        remaining_time_transcription
                    )}</span>s</span></span>`;

                    if (buffer_diarization && remaining_time_diarization) {
                        speakerLabel += `<span class="label_diarization"><span class="spinner"></span>Diarization lag<span id='timeInfo'><span class="lag-diarization-value">${fmt1(
                            remaining_time_diarization
                        )}</span>s</span></span>`;
                    }
                }

                if (buffer_diarization) {
                    if (isFinalizing) {
                        currentLineText +=
                            (currentLineText.length > 0 && buffer_diarization.trim().length > 0 ? " " : "") + buffer_diarization.trim();
                    } else {
                        currentLineText += `<span class="buffer_diarization">${buffer_diarization}</span>`;
                    }
                }
                if (buffer_transcription) {
                    if (isFinalizing) {
                        currentLineText +=
                            (currentLineText.length > 0 && buffer_transcription.trim().length > 0 ? " " : "") +
                            buffer_transcription.trim();
                    } else {
                        currentLineText += `<span class="buffer_transcription">${buffer_transcription}</span>`;
                    }
                }
            }
            let translationContent = "";
            if (item.translation) {
                translationContent += item.translation.trim();
            }
            if (idx === lines.length - 1 && buffer_translation) {
                const bufferPiece = isFinalizing
                    ? buffer_translation
                    : `<span class="buffer_translation">${buffer_translation}</span>`;
                translationContent += translationContent ? `${bufferPiece}` : bufferPiece;
            }
            if (translationContent.trim().length > 0) {
                currentLineText += `
            <div>
                <div class="label_translation">
                    ${translationIcon}
                    <span class="translation_text">${translationContent}</span>
                </div>
            </div>`;
            }

            return currentLineText.trim().length > 0 || speakerLabel.length > 0
                ? `<p>${speakerLabel}<br/><div class='textcontent'>${currentLineText}</div></p>`
                : `<p>${speakerLabel}<br/></p>`;
        })
        .join("");

    linesTranscriptDiv.innerHTML = linesHtml;
    const transcriptContainer = document.querySelector('.transcript-container');
    if (transcriptContainer) {
        transcriptContainer.scrollTo({ top: transcriptContainer.scrollHeight, behavior: "smooth" });
    }
}

function updateTimer() {
    if (!startTime) return;

    const elapsed = Math.floor((Date.now() - startTime) / 1000);
    const minutes = Math.floor(elapsed / 60).toString().padStart(2, "0");
    const seconds = (elapsed % 60).toString().padStart(2, "0");
    timerElement.textContent = `${minutes}:${seconds}`;
}

function drawWaveform() {
    if (!analyser) return;

    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);
    analyser.getByteTimeDomainData(dataArray);

    waveCtx.clearRect(
        0,
        0,
        waveCanvas.width / (window.devicePixelRatio || 1),
        waveCanvas.height / (window.devicePixelRatio || 1)
    );
    waveCtx.lineWidth = 1;
    waveCtx.strokeStyle = waveStroke;
    waveCtx.beginPath();

    const sliceWidth = (waveCanvas.width / (window.devicePixelRatio || 1)) / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
        const v = dataArray[i] / 128.0;
        const y = (v * (waveCanvas.height / (window.devicePixelRatio || 1))) / 2;

        if (i === 0) {
            waveCtx.moveTo(x, y);
        } else {
            waveCtx.lineTo(x, y);
        }

        x += sliceWidth;
    }

    waveCtx.lineTo(
        waveCanvas.width / (window.devicePixelRatio || 1),
        (waveCanvas.height / (window.devicePixelRatio || 1)) / 2
    );
    waveCtx.stroke();

    animationFrame = requestAnimationFrame(drawWaveform);
}

async function startRecording() {
    try {
        try {
            wakeLock = await navigator.wakeLock.request("screen");
        } catch (err) {
            console.log("Error acquiring wake lock.");
        }

        let stream;

        // chromium extension. in the future, both chrome page audio and mic will be used
        if (isExtension) {
            try {
                stream = await new Promise((resolve, reject) => {
                    chrome.tabCapture.capture({ audio: true }, (s) => {
                        if (s) {
                            resolve(s);
                        } else {
                            reject(new Error('Tab capture failed or not available'));
                        }
                    });
                });

                try {
                    outputAudioContext = new (window.AudioContext || window.webkitAudioContext)();
                    audioSource = outputAudioContext.createMediaStreamSource(stream);
                    audioSource.connect(outputAudioContext.destination);
                } catch (audioError) {
                    console.warn('could not preserve system audio:', audioError);
                }

                statusText.textContent = "Using tab audio capture.";
            } catch (tabError) {
                console.log('Tab capture not available, falling back to microphone', tabError);
                const audioConstraints = selectedMicrophoneId
                    ? { audio: { deviceId: { exact: selectedMicrophoneId } } }
                    : { audio: true };
                stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
                statusText.textContent = "Using microphone audio.";
            }
        } else if (isWebContext) {
            const audioConstraints = selectedMicrophoneId
                ? { audio: { deviceId: { exact: selectedMicrophoneId } } }
                : { audio: true };
            stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
        }

        audioContext = new (window.AudioContext || window.webkitAudioContext)();
        analyser = audioContext.createAnalyser();
        analyser.fftSize = 256;
        microphone = audioContext.createMediaStreamSource(stream);
        microphone.connect(analyser);

        if (serverUseAudioWorklet) {
            if (!audioContext.audioWorklet) {
                throw new Error("AudioWorklet is not supported in this browser");
            }
            // NOTE: pcm_worklet.js is missing, so this might fail if serverUseAudioWorklet is true.
            // Assuming user has files or will provide them.
            await audioContext.audioWorklet.addModule("web/pcm_worklet.js");
            workletNode = new AudioWorkletNode(audioContext, "pcm-forwarder", { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
            microphone.connect(workletNode);

            recorderWorker = new Worker("web/recorder_worker.js");
            recorderWorker.postMessage({
                command: "init",
                config: {
                    sampleRate: audioContext.sampleRate,
                },
            });

            recorderWorker.onmessage = (e) => {
                if (websocket && websocket.readyState === WebSocket.OPEN) {
                    websocket.send(e.data.buffer);
                }
            };

            workletNode.port.onmessage = (e) => {
                const data = e.data;
                const ab = data instanceof ArrayBuffer ? data : data.buffer;
                recorderWorker.postMessage(
                    {
                        command: "record",
                        buffer: ab,
                    },
                    [ab]
                );
            };
        } else {
            try {
                recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
            } catch (e) {
                recorder = new MediaRecorder(stream);
            }
            recorder.ondataavailable = (e) => {
                if (websocket && websocket.readyState === WebSocket.OPEN) {
                    if (e.data && e.data.size > 0) {
                        websocket.send(e.data);
                    }
                }
            };
            recorder.start(chunkDuration);
        }

        startTime = Date.now();
        timerInterval = setInterval(updateTimer, 1000);
        drawWaveform();

        isRecording = true;
        updateUI();
    } catch (err) {
        if (window.location.hostname === "0.0.0.0") {
            statusText.textContent =
                "Error accessing microphone. Browsers may block microphone access on 0.0.0.0. Try using localhost:8000 instead.";
        } else {
            statusText.textContent = "Error accessing microphone. Please allow microphone access.";
        }
        console.error(err);
    }
}

async function stopRecording() {
    if (wakeLock) {
        try {
            await wakeLock.release();
        } catch (e) {
            // ignore
        }
        wakeLock = null;
    }

    userClosing = true;
    waitingForStop = true;

    if (websocket && websocket.readyState === WebSocket.OPEN) {
        const emptyBlob = new Blob([], { type: "audio/webm" });
        websocket.send(emptyBlob);
        statusText.textContent = "Recording stopped. Processing final audio...";
    }

    if (recorder) {
        try {
            recorder.stop();
        } catch (e) {
        }
        recorder = null;
    }

    if (recorderWorker) {
        recorderWorker.terminate();
        recorderWorker = null;
    }

    if (workletNode) {
        try {
            workletNode.port.onmessage = null;
        } catch (e) { }
        try {
            workletNode.disconnect();
        } catch (e) { }
        workletNode = null;
    }

    if (microphone) {
        microphone.disconnect();
        microphone = null;
    }

    if (analyser) {
        analyser = null;
    }

    if (audioContext && audioContext.state !== "closed") {
        try {
            await audioContext.close();
        } catch (e) {
            console.warn("Could not close audio context:", e);
        }
        audioContext = null;
    }

    if (audioSource) {
        audioSource.disconnect();
        audioSource = null;
    }

    if (outputAudioContext && outputAudioContext.state !== "closed") {
        outputAudioContext.close()
        outputAudioContext = null;
    }

    if (animationFrame) {
        cancelAnimationFrame(animationFrame);
        animationFrame = null;
    }

    if (timerInterval) {
        clearInterval(timerInterval);
        timerInterval = null;
    }
    timerElement.textContent = "00:00";
    startTime = null;

    isRecording = false;
    updateUI();
}

async function toggleRecording() {
    if (!isRecording) {
        if (waitingForStop) {
            console.log("Waiting for stop, early return");
            return;
        }
        console.log("Connecting to WebSocket");
        try {
            // Ensure orchestrator is connected
            if (!orchestratorWs || orchestratorWs.readyState !== WebSocket.OPEN) {
                setupOrchestratorWebSocket();
            }

            if (websocket && websocket.readyState === WebSocket.OPEN) {
                await configReady;
                await startRecording();
            } else {
                await setupWebSocket();
                await configReady;
                await startRecording();
            }
        } catch (err) {
            statusText.textContent = "Could not connect to WebSocket or access mic. Aborted.";
            console.error(err);
        }
    } else {
        console.log("Stopping recording");
        stopRecording();
    }
}

function updateUI() {
    recordButton.classList.toggle("recording", isRecording);
    recordButton.disabled = waitingForStop;

    if (waitingForStop) {
        if (statusText.textContent !== "Recording stopped. Processing final audio...") {
            statusText.textContent = "Please wait for processing to complete...";
        }
    } else if (isRecording) {
        statusText.textContent = "";
    } else {
        if (
            statusText.textContent !== "Finished processing audio! Ready to record again." &&
            statusText.textContent !== "Processing finalized or connection closed."
        ) {
            statusText.textContent = "Click to start transcription";
        }
    }
    if (!waitingForStop) {
        recordButton.disabled = false;
    }
}

recordButton.addEventListener("click", toggleRecording);

if (microphoneSelect) {
    microphoneSelect.addEventListener("change", handleMicrophoneChange);
}
document.addEventListener('DOMContentLoaded', async () => {
    try {
        const permissions = await navigator.permissions.query({ name: 'microphone' });
        if (permissions.state === 'granted') {
            await enumerateMicrophones();
        } else {
            statusText.textContent = "Microphone access required.";
            if (requestPermissionBtn) {
                requestPermissionBtn.style.display = 'inline-block';
                requestPermissionBtn.onclick = async () => {
                    try {
                        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
                        stream.getTracks().forEach(track => track.stop());
                        await enumerateMicrophones();
                    } catch (e) {
                        console.error("Permission request failed:", e);
                        statusText.textContent = "Permission denied or failed.";
                    }
                };
            }
        }
        setupOrchestratorWebSocket(); // Connect to orchestrator on load
    } catch (error) {
        console.log("Could not check permissions on load:", error);
    }
});
navigator.mediaDevices.addEventListener('devicechange', async () => {
    console.log('Device change detected, re-enumerating microphones');
    try {
        await enumerateMicrophones();
    } catch (error) {
        console.log("Error re-enumerating microphones:", error);
    }
});


settingsToggle.addEventListener("click", () => {
    settingsDiv.classList.toggle("visible");
    settingsToggle.classList.toggle("active");
});

if (isExtension) {
    async function checkAndRequestPermissions() {
        const micPermission = await navigator.permissions.query({
            name: "microphone",
        });

        const permissionDisplay = document.getElementById("audioPermission");
        if (permissionDisplay) {
            permissionDisplay.innerText = `MICROPHONE: ${micPermission.state}`;
        }
    }
}

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationenhancementNew feature or requestgood first issueGood for newcomers

Projects

Status

Todo

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions