You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/Packages/MusubiKit/Sources/Intercom/PhonePage.swift

152 lines
6.5 KiB

/// The page phones open from the QR code. Colors follow design/tokens.json (dark).
enum PhonePage {
static let html = #"""
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14151B">
<title>Musubi</title>
<style>
:root { --sumi:#14151B; --kasumi:#1E2029; --usuzumi:#2B2E3A; --washi:#ECE9E2; --nezumi:#8E8C96; --ai:#8A99F2; --moegi:#3DBE7A; --kohaku:#E8A23A; }
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--sumi); color: var(--washi);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: max(16px, env(safe-area-inset-top)) 16px 24px; }
h1 { font-size: 20px; font-weight: 600; margin: 8px 0 2px; }
.sub { color: var(--nezumi); margin: 0 0 16px; }
.card { background: var(--kasumi); border: 1px solid var(--usuzumi); border-radius: 12px; padding: 16px; }
label { display: block; color: var(--nezumi); font-size: 14px; margin: 0 0 6px; }
input { width: 100%; font: inherit; color: var(--washi); background: var(--sumi); border: 1px solid var(--usuzumi);
border-radius: 8px; padding: 12px; margin: 0 0 16px; }
input:focus { outline: 2px solid var(--ai); outline-offset: -1px; }
button { width: 100%; font: inherit; font-weight: 600; border: 0; border-radius: 8px; padding: 14px; cursor: pointer; }
.primary { background: var(--ai); color: var(--sumi); }
.secondary { background: var(--usuzumi); color: var(--washi); margin-top: 8px; }
video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 12px; display: block; }
.status { display: flex; align-items: center; gap: 8px; color: var(--nezumi); margin: 12px 0; font-size: 14px; }
.dot { width: 8px; height: 8px; border-radius: 4px; background: var(--usuzumi); flex: none; }
.dot.ok { background: var(--moegi); }
.dot.warn { background: var(--kohaku); }
.who { color: var(--washi); }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Musubi</h1>
<p class="sub">Talkback hören, Sendung sehen.</p>
<form id="join" class="card">
<label for="name">Dein Name</label>
<input id="name" autocomplete="name" required placeholder="z. B. Anna">
<label for="role">Deine Rolle</label>
<input id="role" list="roles" required placeholder="z. B. Kamera 2">
<datalist id="roles"><option>Kamera 1</option><option>Kamera 2</option><option>Kamera 3</option><option>Technik</option></datalist>
<button class="primary" type="submit">Verbinden</button>
</form>
<section id="live" hidden>
<video id="video" playsinline autoplay></video>
<div class="status"><span id="dot" class="dot"></span><span id="status">Verbinde …</span></div>
<button id="unmute" class="primary" hidden>Ton einschalten</button>
<button id="leave" class="secondary">Trennen</button>
</section>
</main>
<script>
const $ = (s) => document.querySelector(s);
let pc = null, sessionId = null, wantConnected = false, retryTimer = null;
try {
$("#name").value = localStorage.getItem("musubi.name") || "";
$("#role").value = localStorage.getItem("musubi.role") || "";
} catch {}
function setStatus(text, kind) {
$("#status").textContent = text;
$("#dot").className = "dot" + (kind ? " " + kind : "");
}
async function connect() {
clearTimeout(retryTimer);
const name = $("#name").value.trim(), role = $("#role").value.trim();
setStatus("Verbinde …", "warn");
pc = new RTCPeerConnection({ iceServers: [] });
const current = pc;
pc.addTransceiver("audio", { direction: "recvonly" });
pc.addTransceiver("video", { direction: "recvonly" });
const stream = new MediaStream();
pc.ontrack = (e) => {
stream.addTrack(e.track);
const video = $("#video");
if (video.srcObject !== stream) video.srcObject = stream;
video.play().then(() => { $("#unmute").hidden = true; }).catch(() => { $("#unmute").hidden = false; });
};
pc.onconnectionstatechange = () => {
if (current !== pc) return;
const state = pc.connectionState;
if (state === "connected") setStatus("Verbunden als " + name + " · " + role, "ok");
if (state === "failed" || state === "disconnected" || state === "closed") reconnectSoon();
};
try {
await pc.setLocalDescription(await pc.createOffer());
const response = await fetch("/api/offer", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, role, sdp: pc.localDescription.sdp })
});
if (!response.ok) throw new Error(await response.text());
const answer = await response.json();
sessionId = answer.id;
await pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
} catch (error) {
reconnectSoon(error && error.message);
}
}
function reconnectSoon(reason) {
if (!wantConnected) return;
setStatus((reason || "Verbindung weg.") + " Neuer Versuch in 3 s.", "warn");
teardown();
clearTimeout(retryTimer);
retryTimer = setTimeout(() => { if (wantConnected) connect(); }, 3000);
}
function teardown() {
if (sessionId) navigator.sendBeacon("/api/leave", JSON.stringify({ id: sessionId }));
sessionId = null;
if (pc) { pc.onconnectionstatechange = null; pc.close(); pc = null; }
}
$("#join").addEventListener("submit", (e) => {
e.preventDefault();
try {
localStorage.setItem("musubi.name", $("#name").value.trim());
localStorage.setItem("musubi.role", $("#role").value.trim());
} catch {}
wantConnected = true;
$("#join").hidden = true;
$("#live").hidden = false;
$("#video").play().catch(() => {}); // unlock playback inside the tap
connect();
});
$("#unmute").addEventListener("click", () => {
$("#video").play().then(() => { $("#unmute").hidden = true; }).catch(() => {});
});
$("#leave").addEventListener("click", () => {
wantConnected = false;
clearTimeout(retryTimer);
teardown();
$("#live").hidden = true;
$("#join").hidden = false;
});
addEventListener("pagehide", teardown);
</script>
</body>
</html>
"""#
}

Powered by TurnKey Linux.