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,正在與伺服器建立連接..."; 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 = ` 14:30
`; 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 = `

本專案為 Android 應用程式

掃描此條碼以安裝至您的實體手機
下載 APK 安裝檔 `; 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(); }); }