← back to Big Red

public/phone.html

171 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Big Red — phone cam</title>
<style>
  :root {
    --bg: #14060a;
    --red: #d8323a;
    --red-glow: #ff5560;
    --paper: #f7efe6;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--paper); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
  .wrap { position: fixed; inset: 0; display: flex; flex-direction: column; }
  video { flex: 1; width: 100%; height: 100%; object-fit: cover; background: #000; }
  .topbar {
    position: absolute; top: env(safe-area-inset-top, 0); left: 0; right: 0;
    padding: 14px 16px;
    display: flex; justify-content: space-between; align-items: center;
    background: linear-gradient(to bottom, rgba(0,0,0,.65), rgba(0,0,0,0));
    color: #fff; font-size: 14px;
    pointer-events: none;
  }
  .brand { font-weight: 900; letter-spacing: .18em; color: var(--red-glow); text-shadow: 0 0 12px rgba(255,85,96,.55); }
  .pill { padding: 4px 10px; border-radius: 999px; background: rgba(0,0,0,.5); font-size: 12px; pointer-events: auto; }
  .pill .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ffb84d; margin-right: 6px; vertical-align: middle; box-shadow: 0 0 6px currentColor; }
  .pill .dot.ok { background: #4caf50; }
  .start {
    position: absolute; left: 50%; bottom: max(28px, env(safe-area-inset-bottom));
    transform: translateX(-50%);
    padding: 16px 28px; border-radius: 999px;
    background: var(--red); color: #fff; border: none;
    font-size: 16px; font-weight: 700; letter-spacing: .04em;
    box-shadow: 0 12px 30px rgba(216,50,58,.45);
  }
  .start:active { background: var(--red-glow); }
  .err {
    position: absolute; left: 16px; right: 16px; bottom: 90px;
    background: rgba(0,0,0,.7); border: 1px solid rgba(255,255,255,.15);
    border-radius: 12px; padding: 12px 14px; font-size: 13px;
    display: none;
  }
  .switch-cam {
    position: absolute; right: 16px; bottom: max(28px, env(safe-area-inset-bottom));
    padding: 12px; border-radius: 50%; background: rgba(0,0,0,.55); color: #fff; border: 1px solid rgba(255,255,255,.18);
    font-size: 18px;
  }
</style>
</head>
<body>
<div class="wrap">
  <video id="cam" autoplay playsinline muted></video>
  <div class="topbar">
    <span class="brand">BIG&nbsp;RED</span>
    <span class="pill"><span class="dot" id="dot"></span><span id="status">tap start</span></span>
  </div>
  <button class="start" id="startBtn">Start camera</button>
  <button class="switch-cam" id="switchBtn" style="display:none">⇄</button>
  <div class="err" id="err"></div>
</div>

<script>
(() => {
  const $ = (id) => document.getElementById(id);
  const cam = $('cam');
  const startBtn = $('startBtn');
  const switchBtn = $('switchBtn');
  const dot = $('dot');
  const statusEl = $('status');
  const errEl = $('err');

  const params = new URLSearchParams(location.search);
  const code = params.get('code');
  if (!code) { errEl.style.display = 'block'; errEl.textContent = 'No pair code in URL.'; return; }

  let stream = null;
  let pc = null;
  let ws = null;
  let facing = 'environment';

  function setStatus(s, ok) {
    statusEl.textContent = s;
    if (ok) dot.classList.add('ok'); else dot.classList.remove('ok');
  }

  async function getStream() {
    if (stream) { stream.getTracks().forEach(t => t.stop()); }
    stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } },
      audio: false
    });
    cam.srcObject = stream;
  }

  async function connect() {
    try { await getStream(); }
    catch (e) {
      errEl.style.display = 'block';
      errEl.textContent = 'Camera error: ' + e.message + '. iOS Safari requires HTTPS — open the https:// cloudflared URL on your phone, not http://192.168.x.x. Then grant camera access.';
      return;
    }
    setStatus('connecting', false);
    startBtn.style.display = 'none';
    switchBtn.style.display = 'block';

    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
    ws = new WebSocket(`${proto}://${location.host}/ws`);
    ws.addEventListener('open', () => {
      ws.send(JSON.stringify({ type: 'hello', role: 'phone', code }));
    });
    ws.addEventListener('message', async (ev) => {
      let msg; try { msg = JSON.parse(ev.data); } catch { return; }
      if (msg.type === 'hello-ok') setStatus('paired, calling…', false);
      if (msg.type === 'peer-ready' || (msg.type === 'peer-joined' && msg.role === 'desktop')) {
        await startCall();
      }
      if (msg.type === 'answer') {
        await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
      }
      if (msg.type === 'ice' && msg.candidate) {
        try { await pc.addIceCandidate(msg.candidate); } catch {}
      }
      if (msg.type === 'error') {
        errEl.style.display = 'block';
        errEl.textContent = 'Error: ' + msg.error;
      }
    });
    ws.addEventListener('close', () => {
      setStatus('disconnected', false);
    });
  }

  async function startCall() {
    if (pc) try { pc.close(); } catch {}
    pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
    for (const track of stream.getVideoTracks()) pc.addTrack(track, stream);
    pc.addEventListener('icecandidate', (e) => {
      if (e.candidate) ws.send(JSON.stringify({ type: 'ice', candidate: e.candidate }));
    });
    pc.addEventListener('connectionstatechange', () => {
      if (pc.connectionState === 'connected') setStatus('streaming', true);
      else if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) setStatus('lost', false);
    });
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription }));
  }

  startBtn.addEventListener('click', connect);
  switchBtn.addEventListener('click', async () => {
    facing = (facing === 'environment') ? 'user' : 'environment';
    try {
      await getStream();
      // replace track on existing pc
      if (pc) {
        const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video');
        const newTrack = stream.getVideoTracks()[0];
        if (sender && newTrack) sender.replaceTrack(newTrack);
      }
    } catch (e) {
      errEl.style.display = 'block';
      errEl.textContent = 'Switch failed: ' + e.message;
    }
  });
})();
</script>
</body>
</html>