← 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 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>