← back to Butlr

views/public/call-live.ejs

322 lines

<%- include('../partials/head', { title, meta_desc }) %>
<%- include('../partials/header') %>

<main class="live-call-page">
  <section class="section">
    <div class="wrap">
      <p class="muted small"><a href="/calls">← All calls</a></p>

      <div class="live-grid">
        <!-- LEFT: Live status card -->
        <article class="live-status-card">
          <header class="live-status-head">
            <h1 id="live-business"><%= call.business_name %></h1>
            <span class="status status-<%= call.status %>" id="live-status-pill"><%= call.status %></span>
          </header>
          <p class="muted small">
            Calling <span id="live-phone"><%= call.business_phone %></span> · we'll ring you at <strong><%= call.callback_phone %></strong> when an agent picks up
          </p>

          <div class="live-timeline">
            <div class="live-stage" data-stage="queued"><span class="live-stage-dot"></span> Queued</div>
            <div class="live-stage" data-stage="dialing"><span class="live-stage-dot"></span> Dialing</div>
            <div class="live-stage" data-stage="on_hold"><span class="live-stage-dot"></span> On hold</div>
            <div class="live-stage" data-stage="connected"><span class="live-stage-dot"></span> Connected — bridging you</div>
            <div class="live-stage" data-stage="done"><span class="live-stage-dot"></span> Done</div>
          </div>

          <div class="live-elapsed">
            Elapsed: <span id="live-elapsed">0s</span>
          </div>

          <section class="live-goal-block">
            <h3>Your goal</h3>
            <p class="prose"><%= call.goal %></p>
          </section>

          <!-- LISTEN LIVE button — gesture-policy unlock for Web Audio -->
          <div id="listen-live-wrap" class="live-actions" style="margin-bottom:.75rem">
            <button id="listen-live-btn" class="btn ghost" style="font-size:1.05rem;padding:1rem 1.4rem;border:2px solid var(--accent,#1a56db);color:var(--accent,#1a56db)">
              🔊 Listen live
            </button>
          </div>
          <div id="listen-banner" style="display:none;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;padding:.6rem 1rem;margin-bottom:.75rem;font-size:.92rem;font-weight:600;color:#92400e">
            Call is dialing — click <strong>🔊 Listen live</strong> to hear it live
          </div>
          <div id="listen-status-bar" style="display:none;font-size:.78rem;color:var(--ink-dim,#6b7280);margin-bottom:.75rem;font-family:monospace">
            <span id="listen-ws-state">disconnected</span> &nbsp;·&nbsp; <span id="listen-frame-count">0 frames</span>
          </div>

          <div class="live-actions">
            <button id="bridge-now" class="btn primary" style="font-size:1.05rem;padding:1rem 1.4rem">
              📞 Bridge me NOW — connect my phone
            </button>
          </div>
          <p class="muted small" style="margin-top:.5rem">
            Click <strong>🔊 Listen live</strong> first to open the audio stream. The moment you hear a real human, click "Bridge me now" — your phone rings in 2 seconds and you're connected. Use this when AMD didn't auto-detect the human.
          </p>
          <div class="live-actions" style="margin-top:1rem">
            <a class="btn ghost" href="/calls/<%= call.id %>">Detail view</a>
            <a class="btn ghost" href="/calls/<%= call.id %>?reveal=1">Reveal account info</a>
          </div>
        </article>

        <!-- RIGHT: Butlr AI chat (qwen3:14b via Ollama, local) -->
        <aside class="live-chat-card">
          <header class="live-chat-head">
            <span class="brand-mark">🎩</span>
            <div>
              <strong>Butlr Assistant</strong>
              <span class="muted small">Chat while we wait on hold</span>
            </div>
          </header>
          <div class="live-chat-messages" id="chat-messages">
            <div class="chat-msg butlr">
              <strong>Butlr</strong>
              <p>Hi <%= call.callback_name || 'there' %>. I'm on the line with <%= call.business_name %> right now. Status: <strong><%= call.status %></strong>. While we wait, tell me anything else you want me to mention to the rep — or just ask me what's happening.</p>
            </div>
          </div>
          <form id="chat-form" class="live-chat-form">
            <textarea id="chat-input" rows="2" placeholder="Ask anything, or add a note for the rep…" maxlength="800" required></textarea>
            <button type="submit" class="btn primary" id="chat-send">Send</button>
          </form>
        </aside>
      </div>
    </div>
  </section>
</main>

<%- include('../partials/footer') %>
<style>
@keyframes listen-pulse {
  0%,100% { box-shadow: 0 0 0 0 rgba(245,158,11,.6); border-color: #f59e0b; color: #92400e; }
  50%      { box-shadow: 0 0 0 8px rgba(245,158,11,0); border-color: #d97706; color: #78350f; }
}
#listen-live-btn.pulse {
  animation: listen-pulse 1.2s ease-in-out infinite;
}
</style>
<script>
(function() {
  const callId = <%- JSON.stringify(call.id) %>;
  const createdAt = new Date(<%- JSON.stringify(call.created_at) %>).getTime();
  const STAGE_ORDER = ['queued','dialing','on_hold','connected','done'];
  const statusPill = document.getElementById('live-status-pill');
  const stages = Array.from(document.querySelectorAll('.live-stage'));
  const elapsedEl = document.getElementById('live-elapsed');

  function fmtElapsed(ms) {
    const s = Math.floor(ms / 1000);
    if (s < 60) return s + 's';
    return Math.floor(s/60) + 'm ' + (s%60) + 's';
  }
  function updateStages(status) {
    const idx = STAGE_ORDER.indexOf(status);
    stages.forEach((el, i) => {
      el.classList.toggle('done',    i <  idx);
      el.classList.toggle('current', i === idx);
    });
  }
  setInterval(() => { elapsedEl.textContent = fmtElapsed(Date.now() - createdAt); }, 1000);

  let lastStatus = <%- JSON.stringify(call.status) %>;
  updateStages(lastStatus);

  async function poll() {
    try {
      const r = await fetch('/api/calls/' + callId + '/status', { cache: 'no-store' });
      const j = await r.json();
      if (!j.ok) return;
      if (j.status !== lastStatus) {
        lastStatus = j.status;
        statusPill.textContent = j.status;
        statusPill.className = 'status status-' + j.status;
        updateStages(j.status);
        // System message in chat
        const msgEl = document.createElement('div');
        msgEl.className = 'chat-msg system';
        msgEl.innerHTML = '<em>Status changed → <strong>' + j.status.replace(/_/g,' ') + '</strong></em>';
        document.getElementById('chat-messages').appendChild(msgEl);
        msgEl.scrollIntoView({behavior:'smooth'});
        // If connected, alert + bigger visual
        if (j.status === 'connected') {
          document.title = '📞 PICK UP — ' + j.business_name;
          // Only beep if the user has already unlocked audio via "🔊 Listen live".
          // Creating a fresh AudioContext from a poll callback (no gesture) gets
          // silently auto-suspended by Chrome — guard so we don't burn cycles.
          if (window.__butlrAudioCtx) {
            try {
              const ctx = window.__butlrAudioCtx;
              const osc = ctx.createOscillator();
              const gain = ctx.createGain();
              osc.connect(gain); gain.connect(ctx.destination);
              osc.frequency.value = 880; gain.gain.value = .25;
              osc.start(); osc.stop(ctx.currentTime + 0.4);
            } catch (_) {}
          }
        }
      }
    } catch (e) {}
  }
  setInterval(poll, 2000);
  poll();  // immediate

  // ── AI Chat ──────────────────────────────────────────────────────
  const form = document.getElementById('chat-form');
  const input = document.getElementById('chat-input');
  const sendBtn = document.getElementById('chat-send');
  const msgs = document.getElementById('chat-messages');

  function appendMsg(who, text) {
    const el = document.createElement('div');
    el.className = 'chat-msg ' + who;
    const strong = document.createElement('strong');
    strong.textContent = who === 'you' ? 'You' : 'Butlr';
    const p = document.createElement('p');
    p.textContent = text;
    el.appendChild(strong); el.appendChild(p);
    msgs.appendChild(el);
    el.scrollIntoView({behavior:'smooth'});
  }

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const text = input.value.trim();
    if (!text) return;
    appendMsg('you', text);
    input.value = '';
    sendBtn.disabled = true;
    sendBtn.textContent = 'Thinking…';
    try {
      const r = await fetch('/api/calls/' + callId + '/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: text }),
      });
      const j = await r.json();
      appendMsg('butlr', j.reply || '(no reply)');
    } catch (e) {
      appendMsg('butlr', '(AI offline)');
    } finally {
      sendBtn.disabled = false;
      sendBtn.textContent = 'Send';
      input.focus();
    }
  });

  input.focus();

  // ── Listen-live audio (inline, gesture-unlocked) ──────────────────
  (function() {
    const SAMPLE_RATE = 8000;
    const listenBtn   = document.getElementById('listen-live-btn');
    const banner      = document.getElementById('listen-banner');
    const statusBar   = document.getElementById('listen-status-bar');
    const wsStateEl   = document.getElementById('listen-ws-state');
    const frameEl     = document.getElementById('listen-frame-count');
    let audioCtx   = null;
    let ws         = null;
    let frameCount = 0;
    let nextPlayTime = 0;
    let listening  = false;

    function connect() {
      listening = true;
      banner.style.display = 'none';
      statusBar.style.display = 'block';
      listenBtn.textContent = '🔇 Stop listening';
      listenBtn.classList.remove('pulse');

      audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
      window.__butlrAudioCtx = audioCtx;  // unlocks the "connected" beep below
      nextPlayTime = audioCtx.currentTime + 0.1;

      const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
      ws = new WebSocket(`${scheme}//${location.host}/listen-ws/${callId}`);
      ws.binaryType = 'arraybuffer';

      ws.onopen  = () => { wsStateEl.textContent = 'connected · waiting for audio'; };
      ws.onclose = () => { wsStateEl.textContent = 'disconnected'; if (listening) { listening = false; listenBtn.textContent = '🔊 Listen live'; } };
      ws.onerror = () => { wsStateEl.textContent = 'error'; };
      ws.onmessage = (ev) => {
        // Server sends PCM16 LE @ 8 kHz mono (same as listen.ejs)
        const pcm16   = new Int16Array(ev.data);
        const float32 = new Float32Array(pcm16.length);
        for (let i = 0; i < pcm16.length; i++) float32[i] = pcm16[i] / 32768;
        const buf = audioCtx.createBuffer(1, float32.length, SAMPLE_RATE);
        buf.copyToChannel(float32, 0);
        const src = audioCtx.createBufferSource();
        src.buffer = buf;
        src.connect(audioCtx.destination);
        const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
        src.start(startAt);
        nextPlayTime = startAt + buf.duration;
        frameCount++;
        if (frameCount % 25 === 0) frameEl.textContent = frameCount + ' frames';
        if (wsStateEl.textContent !== 'streaming') wsStateEl.textContent = 'streaming';
      };
    }

    function disconnectAudio() {
      listening = false;
      if (ws) { ws.close(); ws = null; }
      if (audioCtx) { audioCtx.close(); audioCtx = null; window.__butlrAudioCtx = null; }
      listenBtn.textContent = '🔊 Listen live';
      statusBar.style.display = 'none';
      frameCount = 0;
    }

    listenBtn.addEventListener('click', () => {
      if (listening) disconnectAudio(); else connect();
    });

    // When status hits dialing/on_hold, pulse the button and show the banner
    // The existing poll() updates `lastStatus` — we hook in via a MutationObserver
    // on the status pill which is already updated by poll().
    const pill = document.getElementById('live-status-pill');
    if (pill) {
      const obs = new MutationObserver(() => {
        const s = pill.textContent.trim();
        if ((s === 'dialing' || s === 'on_hold') && !listening) {
          listenBtn.classList.add('pulse');
          banner.style.display = 'block';
        }
        // Auto-close WSS when call ends
        if ((s === 'done' || s === 'failed') && listening) {
          disconnectAudio();
        }
      });
      obs.observe(pill, { childList: true, characterData: true, subtree: true });
    }
  })();

  // ── Manual bridge button ──────────────────────────────────────────
  const bridgeBtn = document.getElementById('bridge-now');
  if (bridgeBtn) {
    bridgeBtn.addEventListener('click', async () => {
      if (!confirm('Bridge your phone to the call NOW? Your phone will ring within 2 seconds.')) return;
      bridgeBtn.disabled = true;
      bridgeBtn.textContent = '📞 Bridging…';
      try {
        const r = await fetch('/twilio/bridge/' + callId, { method: 'POST' });
        const j = await r.json();
        if (j.ok) {
          bridgeBtn.textContent = '✓ Bridge fired — answer your phone';
          const sysMsg = document.createElement('div');
          sysMsg.className = 'chat-msg system';
          sysMsg.innerHTML = '<em>Bridge fired. Your phone (' + <%- JSON.stringify(call.callback_phone) %> + ') should ring in ~2 seconds.</em>';
          document.getElementById('chat-messages').appendChild(sysMsg);
        } else {
          bridgeBtn.textContent = '✗ Bridge failed — retry';
          bridgeBtn.disabled = false;
          alert('Bridge failed: ' + (j.error || 'unknown'));
        }
      } catch (e) {
        bridgeBtn.textContent = '✗ Bridge error — retry';
        bridgeBtn.disabled = false;
      }
    });
  }
})();
</script>