Project_Demo / static /js /player.js
steveyehtw's picture
Harden demo runtime and add browser Pygame runner
7547388
Raw History Blame Contribute Delete
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();
});
}