screenshare.html 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Screen Share — Arozcast</title>
  7. <script src="../script/jquery.min.js"></script>
  8. <script src="../script/ao_module.js"></script>
  9. <style>
  10. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  11. :root {
  12. --bg: #0a0a0f;
  13. --card: rgba(255,255,255,.05);
  14. --border: rgba(255,255,255,.1);
  15. --accent: #b0b0b0;
  16. --text: #f0f0f5;
  17. --text2: #9090a8;
  18. --green: #22c55e;
  19. --red: #ef4444;
  20. --blue: #3b82f6;
  21. }
  22. html, body {
  23. min-height: 100vh;
  24. background: var(--bg);
  25. color: var(--text);
  26. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  27. display: flex;
  28. align-items: center;
  29. justify-content: center;
  30. padding: 24px;
  31. }
  32. .container { width: 100%; max-width: 480px; }
  33. .header {
  34. display: flex; align-items: center; gap: 14px;
  35. margin-bottom: 32px;
  36. }
  37. .brand-icon { width: 42px; height: 42px; }
  38. .brand-name { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
  39. .brand-sub { font-size: 12px; color: var(--text2); margin-top: 2px; }
  40. .card {
  41. background: var(--card);
  42. border: 1px solid var(--border);
  43. border-radius: 16px;
  44. padding: 24px;
  45. margin-bottom: 16px;
  46. }
  47. .card-title {
  48. font-size: 11px; text-transform: uppercase; letter-spacing: .14em;
  49. color: var(--text2); margin-bottom: 16px;
  50. }
  51. .code-row { display: flex; gap: 10px; }
  52. .code-input {
  53. flex: 1;
  54. background: rgba(255,255,255,.07);
  55. border: 1.5px solid var(--border);
  56. border-radius: 10px;
  57. padding: 12px 16px;
  58. color: var(--text);
  59. font-size: 28px; font-weight: 700; letter-spacing: .25em;
  60. text-align: center; outline: none;
  61. transition: border-color .15s;
  62. }
  63. .code-input:focus { border-color: rgba(255,255,255,.3); }
  64. .code-input::placeholder { color: var(--text2); font-size: 16px; letter-spacing: .1em; }
  65. .btn {
  66. padding: 12px 20px;
  67. border-radius: 10px; border: none; cursor: pointer;
  68. font-size: 14px; font-weight: 600;
  69. transition: opacity .15s, transform .1s;
  70. }
  71. .btn:active { transform: scale(.97); }
  72. .btn:disabled { opacity: 0.4; cursor: default; transform: none; }
  73. .btn-primary { background: var(--accent); color: #000; }
  74. .btn-green { background: var(--green); color: #000; }
  75. .btn-red { background: var(--red); color: #fff; }
  76. .btn-ghost { background: rgba(255,255,255,.08); color: var(--text); }
  77. .status-row {
  78. display: flex; align-items: center; gap: 8px;
  79. font-size: 13px; color: var(--text2);
  80. }
  81. .dot {
  82. width: 8px; height: 8px; border-radius: 50%;
  83. background: var(--text2); flex-shrink: 0;
  84. }
  85. .dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
  86. .dot.blue { background: var(--blue); box-shadow: 0 0 6px var(--blue); }
  87. .dot.red { background: var(--red); }
  88. #localPreview {
  89. width: 100%; border-radius: 10px; background: #000;
  90. aspect-ratio: 16/9; object-fit: contain;
  91. display: none; margin-top: 16px;
  92. }
  93. #localPreview.visible { display: block; }
  94. .controls-row { display: flex; gap: 10px; margin-top: 16px; }
  95. .controls-row .btn { flex: 1; }
  96. .error-msg {
  97. font-size: 13px; color: #fca5a5;
  98. background: rgba(239,68,68,.08);
  99. border: 1px solid rgba(239,68,68,.2);
  100. border-radius: 8px; padding: 10px 14px;
  101. margin-top: 12px; display: none;
  102. }
  103. .error-msg.visible { display: block; }
  104. .hint {
  105. font-size: 12px; color: var(--text2);
  106. line-height: 1.65; margin-top: 14px;
  107. }
  108. </style>
  109. </head>
  110. <body>
  111. <div class="container">
  112. <div class="header">
  113. <svg class="brand-icon" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
  114. <rect width="48" height="48" rx="12" fill="#b0b0b0"/>
  115. <path d="M34 14H14a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4v-2h-4V16h20v4h2v-4a2 2 0 0 0-2-2Z" fill="white"/>
  116. <path d="M10 36v-4a6 6 0 0 1 6 6h-4a2 2 0 0 0-2-2Zm0 4h4a10 10 0 0 0-4-8v4a6 6 0 0 1 4 4Zm0 0h-2v-2h2v2Z" fill="white" fill-opacity="0.8"/>
  117. <path d="M22 38h-4a14 14 0 0 0-14-14v4a10 10 0 0 1 10 10h4a14 14 0 0 0-14-14v-4a18 18 0 0 1 18 18Z" fill="white" fill-opacity="0.5"/>
  118. </svg>
  119. <div>
  120. <div class="brand-name">Arozcast</div>
  121. <div class="brand-sub">Screen Share</div>
  122. </div>
  123. </div>
  124. <!-- Step 1: connect to room -->
  125. <div class="card" id="connectCard">
  126. <div class="card-title">Step 1 — Connect to receiver</div>
  127. <div class="code-row">
  128. <input class="code-input" id="codeInput" type="text" inputmode="numeric"
  129. maxlength="4" placeholder="0000" autocomplete="off">
  130. <button class="btn btn-primary" id="connectBtn" onclick="connectRoom()">Connect</button>
  131. </div>
  132. <div style="margin-top:12px;">
  133. <div class="status-row">
  134. <div class="dot" id="connDot"></div>
  135. <span id="connStatus">Enter the 4-digit code shown on the Arozcast receiver</span>
  136. </div>
  137. </div>
  138. <div class="error-msg" id="connectError"></div>
  139. </div>
  140. <!-- Step 2: share controls (shown after connecting) -->
  141. <div class="card" id="shareCard" style="display:none;">
  142. <div class="card-title">Step 2 — Share your screen</div>
  143. <div class="status-row">
  144. <div class="dot green" id="shareDot"></div>
  145. <span id="shareStatus">Connected</span>
  146. </div>
  147. <!-- Local preview (shown while sharing) -->
  148. <video id="localPreview" muted playsinline autoplay></video>
  149. <div class="controls-row">
  150. <button class="btn btn-green" id="startBtn" onclick="startShare()">Start sharing</button>
  151. <button class="btn btn-red" id="stopBtn" onclick="stopShare()" style="display:none;" disabled>Stop sharing</button>
  152. <button class="btn btn-ghost" id="disconnectBtn" onclick="disconnect()">Disconnect</button>
  153. </div>
  154. <div class="error-msg" id="shareError"></div>
  155. <p class="hint">
  156. Your screen will be streamed live to the Arozcast receiver via a direct peer-to-peer connection.
  157. System audio is included when your browser and OS support it.
  158. </p>
  159. </div>
  160. </div>
  161. <script>
  162. // ── WebRTC configuration ───────────────────────────────────────────────────
  163. const RTC_CONFIG = {
  164. iceServers: [
  165. { urls: 'stun:stun.l.google.com:19302' },
  166. { urls: 'stun:stun1.l.google.com:19302' },
  167. ]
  168. };
  169. // ── State ─────────────────────────────────────────────────────────────────
  170. let ws = null;
  171. let pc = null; // RTCPeerConnection
  172. let stream = null; // MediaStream from getDisplayMedia
  173. let currentCode = null;
  174. let heartbeatTimer = null;
  175. let isSharing = false;
  176. let iceQueue = []; // ICE candidates buffered before remote description is set
  177. // ── DOM refs ──────────────────────────────────────────────────────────────
  178. const codeInput = document.getElementById('codeInput');
  179. const connectBtn = document.getElementById('connectBtn');
  180. const connDot = document.getElementById('connDot');
  181. const connStatus = document.getElementById('connStatus');
  182. const connectError = document.getElementById('connectError');
  183. const connectCard = document.getElementById('connectCard');
  184. const shareCard = document.getElementById('shareCard');
  185. const shareStatus = document.getElementById('shareStatus');
  186. const shareDot = document.getElementById('shareDot');
  187. const startBtn = document.getElementById('startBtn');
  188. const stopBtn = document.getElementById('stopBtn');
  189. const disconnectBtn = document.getElementById('disconnectBtn');
  190. const shareError = document.getElementById('shareError');
  191. const localPreview = document.getElementById('localPreview');
  192. // ── Helpers ───────────────────────────────────────────────────────────────
  193. function showError(el, msg) { el.textContent = msg; el.classList.add('visible'); }
  194. function clearError(el) { el.classList.remove('visible'); }
  195. function setConnStatus(dotClass, html) {
  196. connDot.className = 'dot' + (dotClass ? ' ' + dotClass : '');
  197. connStatus.innerHTML = html;
  198. }
  199. function setShareStatus(dotClass, html) {
  200. shareDot.className = 'dot' + (dotClass ? ' ' + dotClass : '');
  201. shareStatus.innerHTML = html;
  202. }
  203. function wsSend(topic, payload) {
  204. if (ws && ws.readyState === WebSocket.OPEN) {
  205. ws.send(JSON.stringify({ topic, payload: payload || {} }));
  206. }
  207. }
  208. // ── Room connection ───────────────────────────────────────────────────────
  209. async function connectRoom() {
  210. const code = codeInput.value.trim();
  211. if (!/^\d{4}$/.test(code)) {
  212. showError(connectError, 'Please enter a valid 4-digit room code.');
  213. return;
  214. }
  215. clearError(connectError);
  216. connectBtn.disabled = true;
  217. codeInput.disabled = true;
  218. setConnStatus('', 'Checking room…');
  219. const root = (typeof ao_root !== 'undefined' && ao_root) ? ao_root : '/';
  220. try {
  221. const res = await fetch(root + 'api/arozcast/ping?code=' + code);
  222. const data = await res.json();
  223. if (!data.exists) {
  224. showError(connectError, 'Room ' + code + ' not found. Check the code on the Arozcast receiver.');
  225. connectBtn.disabled = false;
  226. codeInput.disabled = false;
  227. setConnStatus('', 'Enter the 4-digit code shown on the Arozcast receiver');
  228. return;
  229. }
  230. } catch (e) {
  231. showError(connectError, 'Could not reach the server. Check your connection.');
  232. connectBtn.disabled = false;
  233. codeInput.disabled = false;
  234. return;
  235. }
  236. setConnStatus('', 'Connecting…');
  237. const wsUrl = new URL(root + 'api/arozcast/ws?code=' + code, window.location.href);
  238. wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
  239. ws = new WebSocket(wsUrl.toString());
  240. ws.onopen = () => {
  241. currentCode = code;
  242. wsSend('peer.hello', {});
  243. heartbeatTimer = setInterval(() => wsSend('peer.heartbeat', {}), 5000);
  244. connectCard.style.display = 'none';
  245. shareCard.style.display = 'block';
  246. setShareStatus('green', 'Connected to room <strong>' + code + '</strong>');
  247. };
  248. ws.onmessage = (evt) => {
  249. let msg;
  250. try { msg = JSON.parse(evt.data); } catch (_) { return; }
  251. handleMessage(msg);
  252. };
  253. ws.onclose = () => {
  254. clearInterval(heartbeatTimer);
  255. heartbeatTimer = null;
  256. if (isSharing) _cleanupRTC();
  257. if (currentCode) {
  258. setShareStatus('red',
  259. 'Disconnected. <a href="" onclick="location.reload();return false;" style="color:#93c5fd;">Reconnect</a>');
  260. }
  261. };
  262. ws.onerror = () => {};
  263. }
  264. // ── Incoming message handler ──────────────────────────────────────────────
  265. async function handleMessage(msg) {
  266. switch (msg.topic) {
  267. case 'webrtc.answer':
  268. if (pc && pc.signalingState === 'have-local-offer') {
  269. try {
  270. await pc.setRemoteDescription({ type: 'answer', sdp: msg.payload.sdp });
  271. for (const c of iceQueue) await pc.addIceCandidate(c).catch(() => {});
  272. iceQueue = [];
  273. } catch (e) {
  274. showError(shareError, 'WebRTC negotiation failed: ' + e.message);
  275. }
  276. }
  277. break;
  278. case 'webrtc.ice':
  279. if (pc) {
  280. if (pc.remoteDescription) {
  281. pc.addIceCandidate(msg.payload.candidate).catch(() => {});
  282. } else {
  283. iceQueue.push(msg.payload.candidate);
  284. }
  285. }
  286. break;
  287. case 'room.closed':
  288. clearInterval(heartbeatTimer);
  289. if (isSharing) _cleanupRTC();
  290. setShareStatus('red', 'Room was closed by the server.');
  291. break;
  292. }
  293. }
  294. // ── Screen sharing ────────────────────────────────────────────────────────
  295. async function startShare() {
  296. clearError(shareError);
  297. startBtn.disabled = true;
  298. try {
  299. stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
  300. } catch (e) {
  301. showError(shareError,
  302. e.name === 'NotAllowedError'
  303. ? 'Screen capture permission denied.'
  304. : 'Could not start screen capture: ' + e.message);
  305. startBtn.disabled = false;
  306. return;
  307. }
  308. localPreview.srcObject = stream;
  309. localPreview.classList.add('visible');
  310. iceQueue = [];
  311. pc = new RTCPeerConnection(RTC_CONFIG);
  312. pc.onicecandidate = (e) => {
  313. if (e.candidate) wsSend('webrtc.ice', { candidate: e.candidate.toJSON() });
  314. };
  315. pc.onconnectionstatechange = () => {
  316. if (!pc) return;
  317. if (pc.connectionState === 'connected') {
  318. setShareStatus('blue', 'Streaming to room <strong>' + currentCode + '</strong>');
  319. } else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
  320. showError(shareError, 'Peer connection lost. The receiver may have closed.');
  321. stopShare(true);
  322. }
  323. };
  324. for (const track of stream.getTracks()) {
  325. pc.addTrack(track, stream);
  326. }
  327. // Handle user pressing the browser's native "Stop sharing" button
  328. stream.getVideoTracks()[0].onended = () => stopShare();
  329. wsSend('screen.start', {});
  330. try {
  331. const offer = await pc.createOffer();
  332. await pc.setLocalDescription(offer);
  333. wsSend('webrtc.offer', { sdp: offer.sdp, type: offer.type });
  334. } catch (e) {
  335. showError(shareError, 'Failed to create WebRTC offer: ' + e.message);
  336. stopShare(true);
  337. return;
  338. }
  339. isSharing = true;
  340. startBtn.style.display = 'none';
  341. stopBtn.style.display = 'block';
  342. stopBtn.disabled = false;
  343. disconnectBtn.disabled = true;
  344. setShareStatus('blue', 'Connecting to receiver… room <strong>' + currentCode + '</strong>');
  345. }
  346. function stopShare(silent) {
  347. _cleanupRTC();
  348. if (!silent) wsSend('screen.stop', {});
  349. startBtn.style.display = 'block';
  350. startBtn.disabled = false;
  351. stopBtn.style.display = 'none';
  352. stopBtn.disabled = true;
  353. disconnectBtn.disabled = false;
  354. setShareStatus('green', 'Connected to room <strong>' + currentCode + '</strong>');
  355. }
  356. function _cleanupRTC() {
  357. isSharing = false;
  358. if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
  359. if (pc) { pc.close(); pc = null; }
  360. localPreview.srcObject = null;
  361. localPreview.classList.remove('visible');
  362. }
  363. function disconnect() {
  364. if (isSharing) stopShare();
  365. clearInterval(heartbeatTimer);
  366. heartbeatTimer = null;
  367. currentCode = null;
  368. if (ws) { ws.onclose = null; ws.close(); ws = null; }
  369. connectCard.style.display = 'block';
  370. shareCard.style.display = 'none';
  371. connectBtn.disabled = false;
  372. codeInput.disabled = false;
  373. codeInput.value = '';
  374. setConnStatus('', 'Enter the 4-digit code shown on the Arozcast receiver');
  375. }
  376. // ── Keyboard shortcut ────────────────────────────────────────────────────
  377. codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') connectRoom(); });
  378. // ── Clean up on unload ────────────────────────────────────────────────────
  379. window.addEventListener('beforeunload', () => {
  380. if (isSharing) _cleanupRTC();
  381. if (ws) { ws.onclose = null; ws.close(); }
  382. });
  383. </script>
  384. </body>
  385. </html>