Spaces:
Running
Running
Download static/js/player.js from Telearn/Project_Demo: direct link, hf CLI and curl.
- Browser
- Download file 33 kB
-
https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/static/js/player.js
- Command line
-
hf download hf://spaces/Telearn/Project_Demo/static/js/player.js
-
curl -L -o player.js https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/static/js/player.js
33 kB
| const cfg = window.TELEARN_PLAYER || {}; | |
| const className = cfg.className || ""; | |
| const studentName = cfg.studentName || ""; | |
| const studentToken = cfg.studentToken || ""; | |
| const projectName = cfg.projectName || ""; | |
| const projectType = cfg.projectType || ""; | |
| const externalUrl = cfg.externalUrl || ""; | |
| const apkName = cfg.apkName || ""; | |
| const apkSize = cfg.apkSize || ""; | |
| const appetizeKey = cfg.appetizeKey || ""; | |
| const viewOnly = !!cfg.viewOnly; | |
| const sessionTimeoutSec = cfg.sessionTimeoutSec || 900; | |
| const idleWarningSec = cfg.idleWarningSec || 600; | |
| const idleTimeoutSec = cfg.idleTimeoutSec || 720; | |
| const heartbeatIntervalSec = cfg.heartbeatIntervalSec || 20; | |
| const viewportFrame = document.getElementById("viewport-frame"); | |
| const focusPrompt = document.getElementById("focus-prompt-el"); | |
| const consoleLogs = document.getElementById("console-logs"); | |
| const statusWrapper = document.getElementById("server-status"); | |
| const statusText = document.getElementById("status-text"); | |
| const btnToggleWebcam = document.getElementById("btn-toggle-webcam"); | |
| const webcamVideo = document.getElementById("webcam-video"); | |
| const webcamCanvas = document.getElementById("webcam-canvas"); | |
| const webcamCtx = webcamCanvas.getContext("2d"); | |
| const idleDialog = document.getElementById("idle-dialog"); | |
| const idleCountdown = document.getElementById("idle-countdown"); | |
| const btnContinueSession = document.getElementById("btn-continue-session"); | |
| let socket = null; | |
| let oldUrl = null; | |
| let webcamStream = null; | |
| let webcamInterval = null; | |
| let isWebcamStreaming = false; | |
| let isFocused = false; | |
| let heartbeatTimer = null; | |
| let idleCountdownTimer = null; | |
| let idleDeadline = 0; | |
| let lastActivitySentAt = 0; | |
| let stopReason = null; | |
| function sendSocketMessage(message) { | |
| if (!socket || socket.readyState !== WebSocket.OPEN) return false; | |
| socket.send(JSON.stringify(message)); | |
| return true; | |
| } | |
| function sendActivity(force = false) { | |
| const now = Date.now(); | |
| if (!force && now - lastActivitySentAt < 10000) return; | |
| if (sendSocketMessage({ type: "activity" })) { | |
| lastActivitySentAt = now; | |
| hideIdleWarning(); | |
| } | |
| } | |
| function startHeartbeat() { | |
| stopHeartbeat(); | |
| heartbeatTimer = setInterval(() => { | |
| sendSocketMessage({ type: "heartbeat" }); | |
| }, heartbeatIntervalSec * 1000); | |
| } | |
| function stopHeartbeat() { | |
| if (heartbeatTimer) { | |
| clearInterval(heartbeatTimer); | |
| heartbeatTimer = null; | |
| } | |
| } | |
| function formatCountdown(seconds) { | |
| const safeSeconds = Math.max(0, Math.ceil(seconds)); | |
| const minutes = Math.floor(safeSeconds / 60); | |
| return `${minutes}:${String(safeSeconds % 60).padStart(2, "0")}`; | |
| } | |
| function showIdleWarning(remainingSec) { | |
| if (!idleDialog) return; | |
| idleDeadline = Date.now() + Math.max(0, remainingSec) * 1000; | |
| idleDialog.hidden = false; | |
| updateIdleCountdown(); | |
| if (idleCountdownTimer) clearInterval(idleCountdownTimer); | |
| idleCountdownTimer = setInterval(updateIdleCountdown, 250); | |
| if (btnContinueSession) btnContinueSession.focus(); | |
| } | |
| function updateIdleCountdown() { | |
| if (!idleCountdown) return; | |
| idleCountdown.textContent = formatCountdown((idleDeadline - Date.now()) / 1000); | |
| } | |
| function hideIdleWarning() { | |
| if (idleDialog) idleDialog.hidden = true; | |
| if (idleCountdownTimer) { | |
| clearInterval(idleCountdownTimer); | |
| idleCountdownTimer = null; | |
| } | |
| } | |
| function setupActivityTracking() { | |
| ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"].forEach((eventName) => { | |
| window.addEventListener(eventName, () => sendActivity(), { passive: true }); | |
| }); | |
| document.addEventListener("visibilitychange", () => { | |
| if (!document.hidden) sendActivity(true); | |
| }); | |
| if (btnContinueSession) { | |
| btnContinueSession.addEventListener("click", () => sendActivity(true)); | |
| } | |
| } | |
| // Auto-transform Scratch URLs into embed links | |
| function formatEmbedUrl(url) { | |
| if (url.includes("scratch.mit.edu/projects/")) { | |
| const matches = url.match(/\/projects\/(\d+)/); | |
| if (matches && matches[1]) { | |
| return `https://scratch.mit.edu/projects/${matches[1]}/embed`; | |
| } | |
| } | |
| return url; | |
| } | |
| // Initialize view based on project type | |
| function initViewport() { | |
| const layout = document.getElementById("player-layout"); | |
| const sidePanel = document.getElementById("side-panel"); | |
| if (projectType === "python") { | |
| if (viewOnly) { | |
| statusWrapper.classList.remove("status-connecting"); | |
| statusWrapper.classList.add("status-idle"); | |
| statusText.innerText = "僅供預覽"; | |
| document.getElementById("python-device-section").style.display = "none"; | |
| const img = document.createElement("div"); | |
| img.style.cssText = "color:#94a3b8;text-align:center;padding:2rem;max-width:28rem;line-height:1.6;"; | |
| const title = document.createElement("h3"); | |
| title.textContent = projectName; | |
| title.style.cssText = "color:#fff;margin-bottom:0.75rem;"; | |
| const identity = document.createElement("p"); | |
| identity.textContent = `班級:${className}|學生:${studentName}`; | |
| const note = document.createElement("p"); | |
| note.style.cssText = "margin-top:1rem;font-size:0.9rem;"; | |
| note.append("此連結為「僅預覽」模式,不會在伺服器上執行程式。若要實際操作,請使用不含 "); | |
| const query = document.createElement("code"); | |
| query.textContent = "?view=1"; | |
| note.append(query, " 的連結。"); | |
| img.append(title, identity, note); | |
| viewportFrame.appendChild(img); | |
| appendConsole("[系統] view=1 僅預覽模式,未啟動執行器。\n"); | |
| } else { | |
| const img = document.createElement("img"); | |
| img.id = "game-screen"; | |
| img.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='640' height='480' viewBox='0 0 640 480'><rect width='100%' height='100%' fill='%23000'/><text x='50%' y='50%' font-size='20' fill='%2394a3b8' text-anchor='middle' font-family='sans-serif'>正在與伺服器建立連接...</text></svg>"; | |
| img.tabIndex = 1; | |
| viewportFrame.appendChild(img); | |
| focusPrompt.style.display = "flex"; | |
| setLoading(true, "正在與伺服器建立連線…", "啟動 headless 執行器與模型載入中"); | |
| setupPythonControls(img); | |
| connectWebSocket(); | |
| } | |
| } else if (projectType === "apk") { | |
| // Hide Python sections, show APK sections | |
| document.getElementById("python-device-section").style.display = "none"; | |
| document.getElementById("python-terminal-section").style.display = "none"; | |
| statusWrapper.style.display = "none"; | |
| const apkInfoSec = document.getElementById("apk-info-section"); | |
| apkInfoSec.style.display = "flex"; | |
| // Setup downloads & sizes | |
| const dlUrl = window.location.origin + `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/${encodeURIComponent(apkName)}`; | |
| document.getElementById("btn-apk-download").href = dlUrl; | |
| document.getElementById("apk-file-label").innerText = apkName; | |
| document.getElementById("apk-size-label").innerText = apkSize ? `檔案大小: ${apkSize}` : "已偵測到安裝檔"; | |
| // Generate QR code | |
| const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(dlUrl)}`; | |
| const apkQrImage = document.createElement("img"); | |
| apkQrImage.src = qrUrl; | |
| apkQrImage.alt = "QR Code"; | |
| document.getElementById("apk-qr-image").replaceChildren(apkQrImage); | |
| // Setup Phone mockup | |
| viewportFrame.classList.add("iframe-mode"); | |
| viewportFrame.style.background = "transparent"; | |
| viewportFrame.style.boxShadow = "none"; | |
| viewportFrame.style.border = "none"; | |
| viewportFrame.style.padding = "0"; | |
| const phone = document.createElement("div"); | |
| phone.className = "phone-mockup"; | |
| const speaker = document.createElement("div"); | |
| speaker.className = "phone-speaker"; | |
| phone.appendChild(speaker); | |
| const camera = document.createElement("div"); | |
| camera.className = "phone-camera"; | |
| phone.appendChild(camera); | |
| const statusBar = document.createElement("div"); | |
| statusBar.className = "phone-status-bar"; | |
| statusBar.innerHTML = ` | |
| <span>14:30</span> | |
| <div style="display:flex; gap: 4px; align-items:center;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 19.4c3.9 3.9 10.2 3.9 14.1 0l-1.62-1.62C18.26 16.07 19 14.12 19 12c0-4.97-4.03-9-9-9z"/></svg> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg> | |
| </div> | |
| `; | |
| phone.appendChild(statusBar); | |
| const screen = document.createElement("div"); | |
| screen.className = "phone-screen"; | |
| if (appetizeKey) { | |
| const iframe = document.createElement("iframe"); | |
| iframe.className = "phone-screen-iframe"; | |
| iframe.src = `https://appetize.io/embed/${encodeURIComponent(appetizeKey)}?device=pixel4&scale=auto&autoplay=false&orientation=portrait&deviceColor=black`; | |
| iframe.referrerPolicy = "no-referrer"; | |
| screen.appendChild(iframe); | |
| } else { | |
| screen.innerHTML = ` | |
| <div class="android-icon-wrapper"> | |
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#ef8068" stroke-width="1.8"> | |
| <path d="M4 10v6m16-6v6M6 18c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9H6v9zm3-13l-1.5-2M15 5l1.5-2M9 13v.01M15 13v.01" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <h4 id="apk-project-title" style="font-weight: 800; font-size: 1.25rem; color: #ffffff; margin-bottom: 0.5rem; text-shadow: 0 2px 4px rgba(0,0,0,0.3);"></h4> | |
| <p style="font-size: 0.8rem; color: rgba(255,255,255,0.7); line-height: 1.4; padding: 0 1rem;">本專案為 Android 應用程式</p> | |
| </div> | |
| <div style="background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.05); padding: 0.8rem; border-radius: 12px; width: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 8px;"> | |
| <span style="font-size: 0.72rem; color: var(--text-muted); line-height: 1.3;">掃描此條碼以安裝至您的實體手機</span> | |
| <div id="apk-qr-slot"></div> | |
| </div> | |
| <a id="apk-download-link" class="btn-launch btn-launch-apk" style="text-decoration: none; width: 100%; font-size: 0.82rem;" download> | |
| 下載 APK 安裝檔 | |
| </a> | |
| `; | |
| screen.querySelector("#apk-project-title").textContent = projectName; | |
| const qrImage = document.createElement("img"); | |
| qrImage.src = qrUrl; | |
| qrImage.alt = "QR Code"; | |
| qrImage.style.cssText = "width:100px;height:100px;border-radius:6px;border:4px solid #fff;display:block;"; | |
| screen.querySelector("#apk-qr-slot").append(qrImage); | |
| screen.querySelector("#apk-download-link").href = dlUrl; | |
| } | |
| phone.appendChild(screen); | |
| viewportFrame.appendChild(phone); | |
| if (appetizeKey) { | |
| const helper = document.createElement("div"); | |
| helper.style.marginTop = "1rem"; | |
| helper.style.fontSize = "0.85rem"; | |
| helper.style.color = "var(--text-muted)"; | |
| helper.style.textAlign = "center"; | |
| helper.style.maxWidth = "320px"; | |
| helper.style.lineHeight = "1.5"; | |
| helper.append("⚠️ 因 Appetize.io 免費方案限制內嵌功能,若畫面顯示「未啟用內嵌」,請 "); | |
| const openAppetize = document.createElement("a"); | |
| openAppetize.href = `https://appetize.io/app/${encodeURIComponent(appetizeKey)}`; | |
| openAppetize.target = "_blank"; | |
| openAppetize.rel = "noopener noreferrer"; | |
| openAppetize.textContent = "點此在新分頁中打開模擬器"; | |
| openAppetize.style.cssText = "color:#ef8068;text-decoration:underline;font-weight:600;"; | |
| helper.append(openAppetize, "。"); | |
| viewportFrame.appendChild(helper); | |
| } | |
| } else { | |
| // Web based game (HTML or External Link) | |
| // Hide side controls and console log panel, render full viewport width | |
| sidePanel.style.display = "none"; | |
| layout.style.gridTemplateColumns = "1fr"; | |
| statusWrapper.style.display = "none"; | |
| if (projectType === "html") { | |
| viewportFrame.classList.add("iframe-mode"); | |
| const iframe = document.createElement("iframe"); | |
| iframe.className = "iframe-viewport"; | |
| iframe.setAttribute("sandbox", "allow-scripts allow-forms allow-pointer-lock"); | |
| iframe.referrerPolicy = "no-referrer"; | |
| iframe.allow = "autoplay; fullscreen"; | |
| iframe.src = `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/index.html`; | |
| viewportFrame.appendChild(iframe); | |
| } else if (projectType === "external") { | |
| // MakeCode supports an interactive shared-project page. Use a | |
| // credentialless iframe so the app can load under this | |
| // platform's cross-origin isolation headers. | |
| const isMakeCode = /(^|\.)makecode\.com$/i.test(new URL(externalUrl).hostname); | |
| if (isMakeCode) { | |
| viewportFrame.classList.add("iframe-mode"); | |
| const iframe = document.createElement("iframe"); | |
| iframe.className = "iframe-viewport"; | |
| iframe.allow = "autoplay; fullscreen; gamepad"; | |
| iframe.setAttribute("allowfullscreen", "true"); | |
| iframe.setAttribute("credentialless", ""); | |
| iframe.referrerPolicy = "no-referrer"; | |
| iframe.src = externalUrl; | |
| viewportFrame.appendChild(iframe); | |
| return; | |
| } | |
| // Other external sites may send restrictive frame headers; | |
| // open them directly instead of embedding an iframe. | |
| const card = document.createElement("div"); | |
| card.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;min-height:18rem;padding:2rem;text-align:center;color:#e5eefb;"; | |
| const title = document.createElement("h2"); | |
| title.textContent = projectName; | |
| title.style.margin = "0"; | |
| const note = document.createElement("p"); | |
| note.textContent = "此作品位於外部網站,請點擊按鈕開啟。"; | |
| note.style.cssText = "margin:0;color:#94a3b8;"; | |
| const link = document.createElement("a"); | |
| link.href = formatEmbedUrl(externalUrl); | |
| link.target = "_blank"; | |
| link.rel = "noopener noreferrer"; | |
| link.className = "btn-launch btn-launch-html"; | |
| link.textContent = "開啟作品 ↗"; | |
| card.append(title, note, link); | |
| viewportFrame.appendChild(card); | |
| } | |
| } | |
| } | |
| // Connect Pygame/OpenCV WebSocket server | |
| function connectWebSocket() { | |
| const loc = window.location; | |
| const wsProto = loc.protocol === "https:" ? "wss:" : "ws:"; | |
| const wsUrl = `${wsProto}//${loc.host}/ws/play/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}`; | |
| socket = new WebSocket(wsUrl); | |
| socket.binaryType = "blob"; | |
| stopReason = null; | |
| socket.onopen = () => { | |
| statusWrapper.classList.remove("status-stopped", "status-connecting", "status-idle"); | |
| statusWrapper.classList.add("status-running"); | |
| statusText.innerText = "已連線 (運作中)"; | |
| appendConsole("[系統] 連線成功,學生專案已啟動。\n"); | |
| appendConsole(`[系統] 閒置 ${Math.round(idleWarningSec/60)} 分鐘會提醒,${Math.round(idleTimeoutSec/60)} 分鐘後自動釋放名額。\n`); | |
| setLoading(true, "專案執行中…", `閒置 ${Math.round(idleTimeoutSec/60)} 分鐘會自動結束`); | |
| sendActivity(true); | |
| startHeartbeat(); | |
| // Hide loading once first frame or after short delay | |
| setTimeout(() => setLoading(false), 2500); | |
| }; | |
| socket.onmessage = (event) => { | |
| const gameScreen = document.getElementById("game-screen"); | |
| if (event.data instanceof Blob) { | |
| const url = URL.createObjectURL(event.data); | |
| if (gameScreen) { | |
| gameScreen.src = url; | |
| } | |
| if (oldUrl) { | |
| URL.revokeObjectURL(oldUrl); | |
| } | |
| oldUrl = url; | |
| setLoading(false); | |
| } else { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === "log") { | |
| appendConsole(data.data); | |
| } else if (data.type === "idle_warning") { | |
| showIdleWarning(data.remaining_sec || 0); | |
| } else if (data.type === "idle_resumed") { | |
| hideIdleWarning(); | |
| } else if (data.type === "status") { | |
| if (["stopped", "timeout", "idle_timeout", "connection_timeout", "error", "busy", "rate_limited"].includes(data.data)) { | |
| setStoppedStatus(data.data); | |
| setLoading(false); | |
| } else if (data.data === "running") { | |
| statusWrapper.classList.remove("status-stopped", "status-connecting", "status-idle"); | |
| statusWrapper.classList.add("status-running"); | |
| statusText.innerText = "已連線 (運作中)"; | |
| } | |
| } | |
| } catch (e) { | |
| console.error("JSON error:", e); | |
| } | |
| } | |
| }; | |
| socket.onclose = () => { | |
| stopHeartbeat(); | |
| hideIdleWarning(); | |
| setStoppedStatus(stopReason); | |
| if (!stopReason) { | |
| appendConsole("\n[系統] 與伺服器的通訊中斷。\n"); | |
| } | |
| }; | |
| socket.onerror = () => { | |
| appendConsole(`\n[系統] 連線錯誤。\n`); | |
| }; | |
| } | |
| function setStoppedStatus(reason) { | |
| if (reason) stopReason = reason; | |
| statusWrapper.classList.remove("status-running", "status-connecting", "status-idle"); | |
| statusWrapper.classList.add("status-stopped"); | |
| const labels = { | |
| stopped: "已停止", | |
| timeout: "已達執行上限", | |
| idle_timeout: "閒置結束", | |
| connection_timeout: "連線已離開", | |
| error: "錯誤", | |
| busy: "名額已滿", | |
| rate_limited: "啟動太頻繁" | |
| }; | |
| statusText.innerText = labels[reason] || "已停止"; | |
| stopWebcamStream(); | |
| stopHeartbeat(); | |
| hideIdleWarning(); | |
| setLoading(false); | |
| } | |
| function setLoading(show, title, sub) { | |
| const el = document.getElementById("loading-overlay"); | |
| if (!el) return; | |
| el.style.display = show ? "flex" : "none"; | |
| const t = document.getElementById("loading-text"); | |
| const s = document.getElementById("loading-sub"); | |
| if (title && t) t.textContent = title; | |
| if (sub && s) s.textContent = sub; | |
| } | |
| function showWebcamDenied(msg) { | |
| const el = document.getElementById("webcam-denied"); | |
| if (!el) return; | |
| el.style.display = "block"; | |
| const m = document.getElementById("webcam-denied-msg"); | |
| if (m && msg) m.textContent = msg; | |
| } | |
| function hideWebcamDenied() { | |
| const el = document.getElementById("webcam-denied"); | |
| if (el) el.style.display = "none"; | |
| } | |
| function restartProject() { | |
| appendConsole("\n[系統] 正在重新啟動專案...\n"); | |
| if (socket) { | |
| socket.close(); | |
| } | |
| setTimeout(connectWebSocket, 1000); | |
| } | |
| function stopProjectElegant() { | |
| appendConsole("\n[系統] 正在發送退出訊號並結束專案...\n"); | |
| if (projectType === "python" && socket && socket.readyState === WebSocket.OPEN) { | |
| socket.send(JSON.stringify({ | |
| type: "pygame_quit" | |
| })); | |
| // Wait 1.5s before closing to let Pygame script do graceful cleanup | |
| setTimeout(() => { | |
| window.close(); | |
| }, 1500); | |
| } else { | |
| window.close(); | |
| } | |
| } | |
| function appendConsole(text) { | |
| if (consoleLogs) { | |
| consoleLogs.textContent += text; | |
| consoleLogs.scrollTop = consoleLogs.scrollHeight; | |
| } | |
| } | |
| function clearConsole() { | |
| if (consoleLogs) consoleLogs.textContent = ""; | |
| } | |
| // Setup mouse and keyboard input triggers for Python | |
| function setupPythonControls(gameScreen) { | |
| gameScreen.addEventListener("focus", () => { | |
| isFocused = true; | |
| focusPrompt.classList.add("active"); | |
| focusPrompt.querySelector("span").innerText = "控制已啟用 (按 ESC 解除鎖定)"; | |
| }); | |
| gameScreen.addEventListener("blur", () => { | |
| isFocused = false; | |
| focusPrompt.classList.remove("active"); | |
| focusPrompt.querySelector("span").innerText = "點選畫面啟用鍵盤/滑鼠控制"; | |
| }); | |
| gameScreen.addEventListener("click", (e) => { | |
| gameScreen.focus(); | |
| e.preventDefault(); | |
| }); | |
| window.addEventListener("keydown", (e) => { | |
| if (isFocused && ["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) { | |
| e.preventDefault(); | |
| } | |
| }); | |
| window.addEventListener("keydown", (event) => { | |
| if (!isFocused || !socket || socket.readyState !== WebSocket.OPEN) return; | |
| if (event.key === "Escape") { | |
| gameScreen.blur(); | |
| return; | |
| } | |
| socket.send(JSON.stringify({ | |
| type: "keydown", | |
| key: event.key, | |
| unicode: event.key.length === 1 ? event.key : "" | |
| })); | |
| socket.send(JSON.stringify({ | |
| type: "cv2key", | |
| keyCode: event.key.charCodeAt(0) || -1 | |
| })); | |
| }); | |
| window.addEventListener("keyup", (event) => { | |
| if (!isFocused || !socket || socket.readyState !== WebSocket.OPEN) return; | |
| socket.send(JSON.stringify({ | |
| type: "keyup", | |
| key: event.key | |
| })); | |
| }); | |
| function sendMouseEvent(type, event) { | |
| if (!socket || socket.readyState !== WebSocket.OPEN) return; | |
| const rect = gameScreen.getBoundingClientRect(); | |
| const scaleX = gameScreen.naturalWidth / rect.width; | |
| const scaleY = gameScreen.naturalHeight / rect.height; | |
| const x = Math.round((event.clientX - rect.left) * scaleX); | |
| const y = Math.round((event.clientY - rect.top) * scaleY); | |
| socket.send(JSON.stringify({ | |
| type: type, | |
| x: x, | |
| y: y, | |
| button: event.button, | |
| btnLeft: (event.buttons & 1) === 1, | |
| btnMiddle: (event.buttons & 4) === 4, | |
| btnRight: (event.buttons & 2) === 2 | |
| })); | |
| } | |
| gameScreen.addEventListener("mousemove", (e) => sendMouseEvent("mousemove", e)); | |
| gameScreen.addEventListener("mousedown", (e) => sendMouseEvent("mousedown", e)); | |
| gameScreen.addEventListener("mouseup", (e) => sendMouseEvent("mouseup", e)); | |
| gameScreen.addEventListener("dragstart", (e) => e.preventDefault()); | |
| } | |
| // ----------------- Webcam Capture ----------------- | |
| if (btnToggleWebcam) { | |
| btnToggleWebcam.addEventListener("click", () => { | |
| if (isWebcamStreaming) { | |
| stopWebcamStream(); | |
| } else { | |
| startWebcamStream(); | |
| } | |
| }); | |
| } | |
| async function startWebcamStream() { | |
| try { | |
| hideWebcamDenied(); | |
| appendConsole("[系統] 正在取得本機相機鏡頭權限...\n"); | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| throw new Error("此瀏覽器不支援 getUserMedia,請改用 Chrome/Edge,並以 HTTPS 或 localhost 開啟。"); | |
| } | |
| webcamStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 480, max: 640 }, | |
| height: { ideal: 360, max: 480 }, | |
| frameRate: { ideal: 12, max: 15 } | |
| }, | |
| audio: false | |
| }); | |
| webcamVideo.srcObject = webcamStream; | |
| // Match canvas to lower resolution for upload bandwidth | |
| webcamCanvas.width = 480; | |
| webcamCanvas.height = 360; | |
| btnToggleWebcam.classList.add("active"); | |
| btnToggleWebcam.innerText = "關閉視訊鏡頭"; | |
| isWebcamStreaming = true; | |
| appendConsole("[系統] Webcam 開啟成功,開始串流影格(480p)。\n"); | |
| webcamInterval = setInterval(captureWebcamFrame, 80); | |
| } catch (err) { | |
| console.error("Camera error:", err); | |
| const hint = (err && err.name === "NotAllowedError") | |
| ? "您已拒絕攝影機權限。請點網址列旁的相機圖示允許後再試。若使用遠端網址,需 HTTPS。" | |
| : `開啟鏡頭失敗: ${err.message || err}。請確認使用 HTTPS 或 localhost,並允許瀏覽器存取攝影機。系統將使用虛擬測試影像。`; | |
| appendConsole(`[系統] ${hint}\n`); | |
| showWebcamDenied(hint); | |
| stopWebcamStream(); | |
| } | |
| } | |
| function stopWebcamStream() { | |
| if (webcamInterval) { | |
| clearInterval(webcamInterval); | |
| webcamInterval = null; | |
| } | |
| if (webcamStream) { | |
| webcamStream.getTracks().forEach(track => track.stop()); | |
| webcamStream = null; | |
| } | |
| webcamVideo.srcObject = null; | |
| if (btnToggleWebcam) { | |
| btnToggleWebcam.classList.remove("active"); | |
| btnToggleWebcam.innerText = "啟用 Webcam 影像串流"; | |
| } | |
| isWebcamStreaming = false; | |
| } | |
| function captureWebcamFrame() { | |
| if (!socket || socket.readyState !== WebSocket.OPEN) return; | |
| if (webcamVideo.readyState === webcamVideo.HAVE_ENOUGH_DATA) { | |
| webcamCtx.drawImage(webcamVideo, 0, 0, webcamCanvas.width, webcamCanvas.height); | |
| webcamCanvas.toBlob((blob) => { | |
| if (blob && socket.readyState === WebSocket.OPEN) { | |
| socket.send(blob); | |
| } | |
| }, "image/jpeg", 0.5); | |
| } | |
| } | |
| window.onload = initViewport; | |
| document.getElementById("btn-back")?.addEventListener("click", () => window.close()); | |
| document.getElementById("btn-restart")?.addEventListener("click", restartProject); | |
| document.getElementById("btn-stop")?.addEventListener("click", stopProjectElegant); | |
| document.getElementById("btn-clear-console")?.addEventListener("click", clearConsole); | |
| setupActivityTracking(); | |
| window.onbeforeunload = () => { | |
| stopWebcamStream(); | |
| stopHeartbeat(); | |
| if (socket) socket.close(); | |
| }; | |
| // Setup Terminal Input event listener | |
| const terminalInputField = document.getElementById("terminal-input-field"); | |
| if (terminalInputField) { | |
| terminalInputField.addEventListener("keydown", (event) => { | |
| if (event.key === "Enter") { | |
| const text = terminalInputField.value; | |
| if (socket && socket.readyState === WebSocket.OPEN) { | |
| socket.send(JSON.stringify({ | |
| type: "terminal_input", | |
| data: text | |
| })); | |
| appendConsole(`> ${text}\n`); | |
| terminalInputField.value = ""; | |
| } else { | |
| appendConsole("\n[系統] 錯誤: 無法傳送輸入,連線已中斷。\n"); | |
| } | |
| } | |
| }); | |
| } | |
| // Focus input field when clicking console logs area | |
| const consoleBox = document.getElementById("console-logs"); | |
| if (consoleBox) { | |
| consoleBox.addEventListener("click", () => { | |
| if (terminalInputField) terminalInputField.focus(); | |
| }); | |
| } | |