steveyehtw commited on
Commit
8807cbe
·
1 Parent(s): a22790d

Release idle showcase sessions automatically

Browse files
Files changed (4) hide show
  1. server.py +129 -21
  2. static/css/player.css +68 -0
  3. static/js/player.js +110 -6
  4. templates/player.html +12 -0
server.py CHANGED
@@ -34,6 +34,13 @@ MODELS_DIR = os.path.join(BASE_DIR, "models")
34
 
35
  MAX_CONCURRENT_RUNNERS = int(os.environ.get("MAX_CONCURRENT_RUNNERS", "3"))
36
  SESSION_TIMEOUT_SEC = int(os.environ.get("SESSION_TIMEOUT_SEC", "900")) # 15 min
 
 
 
 
 
 
 
37
  PROJECT_CACHE_TTL_SEC = float(os.environ.get("PROJECT_CACHE_TTL_SEC", "10"))
38
  RUNNER_CONNECT_TIMEOUT_SEC = float(os.environ.get("RUNNER_CONNECT_TIMEOUT_SEC", "8"))
39
 
@@ -60,6 +67,8 @@ _metrics = {
60
  "sessions_started": 0,
61
  "sessions_rejected": 0,
62
  "sessions_timed_out": 0,
 
 
63
  "sessions_failed_connect": 0,
64
  }
65
 
@@ -67,6 +76,19 @@ _metrics = {
67
  _project_cache: dict[str, Any] = {"tree": None, "built_at": 0.0, "signature": None}
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # ---------------------------------------------------------------------------
71
  # Project scanning (cached)
72
  # ---------------------------------------------------------------------------
@@ -302,6 +324,9 @@ async def health():
302
  "active_sessions": len(active_sessions),
303
  "max_concurrent_runners": MAX_CONCURRENT_RUNNERS,
304
  "session_timeout_sec": SESSION_TIMEOUT_SEC,
 
 
 
305
  }
306
 
307
 
@@ -452,6 +477,9 @@ async def run_project_page(request: Request, student_token: str, project_name: s
452
  "appetize_key": appetize_key,
453
  "view_only": view_only,
454
  "session_timeout_sec": SESSION_TIMEOUT_SEC,
 
 
 
455
  "max_concurrent_runners": MAX_CONCURRENT_RUNNERS,
456
  },
457
  )
@@ -564,6 +592,9 @@ async def play_project_ws_internal(
564
  await websocket.accept()
565
  session_id = f"{real_class}/{real_student}/{project_name}/{time.time():.0f}"
566
  started_at = time.time()
 
 
 
567
 
568
  async with _sessions_lock:
569
  active_sessions[session_id] = {
@@ -571,6 +602,7 @@ async def play_project_ws_internal(
571
  "student": real_student,
572
  "project": project_name,
573
  "started_at": started_at,
 
574
  }
575
  _metrics["sessions_started"] += 1
576
 
@@ -667,26 +699,41 @@ async def play_project_ws_internal(
667
  log.exception("tcp_to_ws error: %s", e)
668
 
669
  async def ws_to_tcp():
 
670
  try:
671
  while True:
672
  data = await websocket.receive()
 
 
673
  if "text" in data:
674
  message = json.loads(data["text"])
675
  mtype = message.get("type")
676
 
677
- if mtype in ("keydown", "keyup"):
 
 
 
 
 
 
 
 
678
  payload = json.dumps(message).encode("utf-8")
679
  runner_writer.write(
680
  b"KEYS" + len(payload).to_bytes(4, "big") + payload
681
  )
682
  await runner_writer.drain()
683
  elif mtype in ("mousemove", "mousedown", "mouseup"):
 
 
684
  payload = json.dumps(message).encode("utf-8")
685
  runner_writer.write(
686
  b"MOUS" + len(payload).to_bytes(4, "big") + payload
687
  )
688
  await runner_writer.drain()
689
  elif mtype == "cv2key":
 
 
690
  payload = json.dumps(
691
  {"key_code": message.get("keyCode")}
692
  ).encode("utf-8")
@@ -695,6 +742,8 @@ async def play_project_ws_internal(
695
  )
696
  await runner_writer.drain()
697
  elif mtype == "terminal_input":
 
 
698
  text = message.get("data", "")
699
  if not text.endswith("\n"):
700
  text += "\n"
@@ -704,6 +753,7 @@ async def play_project_ws_internal(
704
  )
705
  await runner_writer.drain()
706
  elif mtype == "pygame_quit":
 
707
  runner_writer.write(b"QUIT" + (0).to_bytes(4, "big") + b"")
708
  await runner_writer.drain()
709
  elif "bytes" in data:
@@ -718,34 +768,92 @@ async def play_project_ws_internal(
718
  log.exception("ws_to_tcp error: %s", e)
719
 
720
  async def session_watchdog():
721
- await asyncio.sleep(SESSION_TIMEOUT_SEC)
722
- _metrics["sessions_timed_out"] += 1
723
- log.warning("Session timeout session=%s", session_id)
724
- try:
725
- await websocket.send_json(
726
- {
727
- "type": "log",
728
- "data": f"\n[系統] 已達最長執行時間({SESSION_TIMEOUT_SEC // 60} 分鐘),自動結束。\n",
729
- }
730
- )
731
- await websocket.send_json({"type": "status", "data": "timeout"})
732
- except Exception:
733
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  try:
735
  await websocket.close(code=1000)
736
  except Exception:
737
  pass
738
 
739
- bridge = asyncio.gather(tcp_to_ws(), ws_to_tcp())
 
740
  watchdog = asyncio.create_task(session_watchdog())
741
  try:
742
- await bridge
 
 
 
 
 
 
743
  finally:
744
- watchdog.cancel()
745
- try:
746
- await watchdog
747
- except asyncio.CancelledError:
748
- pass
 
749
 
750
  finally:
751
  if process is not None:
 
34
 
35
  MAX_CONCURRENT_RUNNERS = int(os.environ.get("MAX_CONCURRENT_RUNNERS", "3"))
36
  SESSION_TIMEOUT_SEC = int(os.environ.get("SESSION_TIMEOUT_SEC", "900")) # 15 min
37
+ IDLE_TIMEOUT_SEC = max(1, int(os.environ.get("IDLE_TIMEOUT_SEC", "720"))) # 12 min
38
+ IDLE_WARNING_SEC = min(
39
+ IDLE_TIMEOUT_SEC, max(0, int(os.environ.get("IDLE_WARNING_SEC", "600")))
40
+ ) # warn after 10 min idle
41
+ HEARTBEAT_TIMEOUT_SEC = max(
42
+ 30, int(os.environ.get("HEARTBEAT_TIMEOUT_SEC", "75"))
43
+ )
44
  PROJECT_CACHE_TTL_SEC = float(os.environ.get("PROJECT_CACHE_TTL_SEC", "10"))
45
  RUNNER_CONNECT_TIMEOUT_SEC = float(os.environ.get("RUNNER_CONNECT_TIMEOUT_SEC", "8"))
46
 
 
67
  "sessions_started": 0,
68
  "sessions_rejected": 0,
69
  "sessions_timed_out": 0,
70
+ "sessions_idle_timed_out": 0,
71
+ "sessions_heartbeat_timed_out": 0,
72
  "sessions_failed_connect": 0,
73
  }
74
 
 
76
  _project_cache: dict[str, Any] = {"tree": None, "built_at": 0.0, "signature": None}
77
 
78
 
79
+ def _session_idle_state(
80
+ idle_for_sec: float,
81
+ warning_sec: float = IDLE_WARNING_SEC,
82
+ timeout_sec: float = IDLE_TIMEOUT_SEC,
83
+ ) -> str:
84
+ """Return active, warning, or timeout for a session's idle duration."""
85
+ if idle_for_sec >= timeout_sec:
86
+ return "timeout"
87
+ if warning_sec < timeout_sec and idle_for_sec >= warning_sec:
88
+ return "warning"
89
+ return "active"
90
+
91
+
92
  # ---------------------------------------------------------------------------
93
  # Project scanning (cached)
94
  # ---------------------------------------------------------------------------
 
324
  "active_sessions": len(active_sessions),
325
  "max_concurrent_runners": MAX_CONCURRENT_RUNNERS,
326
  "session_timeout_sec": SESSION_TIMEOUT_SEC,
327
+ "idle_warning_sec": IDLE_WARNING_SEC,
328
+ "idle_timeout_sec": IDLE_TIMEOUT_SEC,
329
+ "heartbeat_timeout_sec": HEARTBEAT_TIMEOUT_SEC,
330
  }
331
 
332
 
 
477
  "appetize_key": appetize_key,
478
  "view_only": view_only,
479
  "session_timeout_sec": SESSION_TIMEOUT_SEC,
480
+ "idle_warning_sec": IDLE_WARNING_SEC,
481
+ "idle_timeout_sec": IDLE_TIMEOUT_SEC,
482
+ "heartbeat_interval_sec": min(20, max(10, HEARTBEAT_TIMEOUT_SEC // 3)),
483
  "max_concurrent_runners": MAX_CONCURRENT_RUNNERS,
484
  },
485
  )
 
592
  await websocket.accept()
593
  session_id = f"{real_class}/{real_student}/{project_name}/{time.time():.0f}"
594
  started_at = time.time()
595
+ started_monotonic = time.monotonic()
596
+ last_activity_at = started_monotonic
597
+ last_client_seen_at = started_monotonic
598
 
599
  async with _sessions_lock:
600
  active_sessions[session_id] = {
 
602
  "student": real_student,
603
  "project": project_name,
604
  "started_at": started_at,
605
+ "last_activity_at": started_at,
606
  }
607
  _metrics["sessions_started"] += 1
608
 
 
699
  log.exception("tcp_to_ws error: %s", e)
700
 
701
  async def ws_to_tcp():
702
+ nonlocal last_activity_at, last_client_seen_at
703
  try:
704
  while True:
705
  data = await websocket.receive()
706
+ now = time.monotonic()
707
+ last_client_seen_at = now
708
  if "text" in data:
709
  message = json.loads(data["text"])
710
  mtype = message.get("type")
711
 
712
+ if mtype == "heartbeat":
713
+ continue
714
+
715
+ if mtype == "activity":
716
+ last_activity_at = now
717
+ active_sessions[session_id]["last_activity_at"] = time.time()
718
+ elif mtype in ("keydown", "keyup"):
719
+ last_activity_at = now
720
+ active_sessions[session_id]["last_activity_at"] = time.time()
721
  payload = json.dumps(message).encode("utf-8")
722
  runner_writer.write(
723
  b"KEYS" + len(payload).to_bytes(4, "big") + payload
724
  )
725
  await runner_writer.drain()
726
  elif mtype in ("mousemove", "mousedown", "mouseup"):
727
+ last_activity_at = now
728
+ active_sessions[session_id]["last_activity_at"] = time.time()
729
  payload = json.dumps(message).encode("utf-8")
730
  runner_writer.write(
731
  b"MOUS" + len(payload).to_bytes(4, "big") + payload
732
  )
733
  await runner_writer.drain()
734
  elif mtype == "cv2key":
735
+ last_activity_at = now
736
+ active_sessions[session_id]["last_activity_at"] = time.time()
737
  payload = json.dumps(
738
  {"key_code": message.get("keyCode")}
739
  ).encode("utf-8")
 
742
  )
743
  await runner_writer.drain()
744
  elif mtype == "terminal_input":
745
+ last_activity_at = now
746
+ active_sessions[session_id]["last_activity_at"] = time.time()
747
  text = message.get("data", "")
748
  if not text.endswith("\n"):
749
  text += "\n"
 
753
  )
754
  await runner_writer.drain()
755
  elif mtype == "pygame_quit":
756
+ last_activity_at = now
757
  runner_writer.write(b"QUIT" + (0).to_bytes(4, "big") + b"")
758
  await runner_writer.drain()
759
  elif "bytes" in data:
 
768
  log.exception("ws_to_tcp error: %s", e)
769
 
770
  async def session_watchdog():
771
+ warning_sent = False
772
+ while True:
773
+ await asyncio.sleep(1)
774
+ now = time.monotonic()
775
+ idle_for = now - last_activity_at
776
+ session_age = now - started_monotonic
777
+ heartbeat_age = now - last_client_seen_at
778
+ idle_state = _session_idle_state(idle_for)
779
+
780
+ if session_age >= SESSION_TIMEOUT_SEC:
781
+ _metrics["sessions_timed_out"] += 1
782
+ log.warning("Session hard timeout session=%s", session_id)
783
+ await websocket.send_json(
784
+ {
785
+ "type": "log",
786
+ "data": f"\n[系統] 已達最長執行時間({SESSION_TIMEOUT_SEC // 60} 分鐘),自動結束。\n",
787
+ }
788
+ )
789
+ await websocket.send_json({"type": "status", "data": "timeout"})
790
+ break
791
+
792
+ if heartbeat_age >= HEARTBEAT_TIMEOUT_SEC:
793
+ _metrics["sessions_heartbeat_timed_out"] += 1
794
+ log.warning("Session heartbeat timeout session=%s", session_id)
795
+ await websocket.send_json(
796
+ {
797
+ "type": "log",
798
+ "data": "\n[系統] 瀏覽器已離線,自動釋放展示名額。\n",
799
+ }
800
+ )
801
+ await websocket.send_json(
802
+ {"type": "status", "data": "connection_timeout"}
803
+ )
804
+ break
805
+
806
+ if idle_state == "timeout":
807
+ _metrics["sessions_idle_timed_out"] += 1
808
+ log.info("Session idle timeout session=%s", session_id)
809
+ await websocket.send_json(
810
+ {
811
+ "type": "log",
812
+ "data": f"\n[系統] 已閒置 {IDLE_TIMEOUT_SEC // 60} 分鐘,自動結束並釋放展示名額。\n",
813
+ }
814
+ )
815
+ await websocket.send_json(
816
+ {"type": "status", "data": "idle_timeout"}
817
+ )
818
+ break
819
+
820
+ if idle_state == "warning" and not warning_sent:
821
+ warning_sent = True
822
+ await websocket.send_json(
823
+ {
824
+ "type": "idle_warning",
825
+ "remaining_sec": max(
826
+ 0, int(IDLE_TIMEOUT_SEC - idle_for)
827
+ ),
828
+ }
829
+ )
830
+ elif idle_state == "active" and warning_sent:
831
+ warning_sent = False
832
+ await websocket.send_json({"type": "idle_resumed"})
833
+
834
  try:
835
  await websocket.close(code=1000)
836
  except Exception:
837
  pass
838
 
839
+ tcp_task = asyncio.create_task(tcp_to_ws())
840
+ ws_task = asyncio.create_task(ws_to_tcp())
841
  watchdog = asyncio.create_task(session_watchdog())
842
  try:
843
+ _, pending = await asyncio.wait(
844
+ {tcp_task, ws_task, watchdog},
845
+ return_when=asyncio.FIRST_COMPLETED,
846
+ )
847
+ for task in pending:
848
+ task.cancel()
849
+ await asyncio.gather(*pending, return_exceptions=True)
850
  finally:
851
+ for task in (tcp_task, ws_task, watchdog):
852
+ if not task.done():
853
+ task.cancel()
854
+ await asyncio.gather(
855
+ tcp_task, ws_task, watchdog, return_exceptions=True
856
+ )
857
 
858
  finally:
859
  if process is not None:
static/css/player.css CHANGED
@@ -752,3 +752,71 @@
752
  .viewport-section {
753
  position: relative;
754
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  .viewport-section {
753
  position: relative;
754
  }
755
+
756
+ .idle-dialog-backdrop {
757
+ position: fixed;
758
+ inset: 0;
759
+ z-index: 100;
760
+ display: grid;
761
+ place-items: center;
762
+ padding: 1.25rem;
763
+ background: rgba(3, 7, 18, 0.78);
764
+ backdrop-filter: blur(8px);
765
+ }
766
+
767
+ .idle-dialog-backdrop[hidden] {
768
+ display: none;
769
+ }
770
+
771
+ .idle-dialog-card {
772
+ width: min(420px, 100%);
773
+ box-sizing: border-box;
774
+ padding: 2rem;
775
+ border: 1px solid rgba(168, 85, 247, 0.35);
776
+ border-radius: 20px;
777
+ background: #111827;
778
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
779
+ color: #f9fafb;
780
+ text-align: center;
781
+ }
782
+
783
+ .idle-dialog-icon {
784
+ margin-bottom: 0.8rem;
785
+ font-size: 2rem;
786
+ }
787
+
788
+ .idle-dialog-card h2 {
789
+ margin: 0 0 0.75rem;
790
+ font-size: 1.45rem;
791
+ }
792
+
793
+ .idle-dialog-card p {
794
+ margin: 0;
795
+ color: #cbd5e1;
796
+ line-height: 1.65;
797
+ }
798
+
799
+ .idle-dialog-card strong {
800
+ color: #fbbf24;
801
+ font-variant-numeric: tabular-nums;
802
+ }
803
+
804
+ .idle-dialog-card button {
805
+ width: 100%;
806
+ margin-top: 1.4rem;
807
+ padding: 0.85rem 1rem;
808
+ border: 0;
809
+ border-radius: 12px;
810
+ background: #7c3aed;
811
+ color: #fff;
812
+ font: inherit;
813
+ font-weight: 700;
814
+ cursor: pointer;
815
+ }
816
+
817
+ .idle-dialog-card button:hover,
818
+ .idle-dialog-card button:focus-visible {
819
+ background: #8b5cf6;
820
+ outline: 3px solid rgba(167, 139, 250, 0.35);
821
+ outline-offset: 3px;
822
+ }
static/js/player.js CHANGED
@@ -11,6 +11,9 @@
11
  const appetizeKey = cfg.appetizeKey || "";
12
  const viewOnly = !!cfg.viewOnly;
13
  const sessionTimeoutSec = cfg.sessionTimeoutSec || 900;
 
 
 
14
 
15
  const viewportFrame = document.getElementById("viewport-frame");
16
  const focusPrompt = document.getElementById("focus-prompt-el");
@@ -21,6 +24,9 @@
21
  const webcamVideo = document.getElementById("webcam-video");
22
  const webcamCanvas = document.getElementById("webcam-canvas");
23
  const webcamCtx = webcamCanvas.getContext("2d");
 
 
 
24
 
25
  let socket = null;
26
  let oldUrl = null;
@@ -28,6 +34,81 @@
28
  let webcamInterval = null;
29
  let isWebcamStreaming = false;
30
  let isFocused = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  // Auto-transform Scratch URLs into embed links
33
  function formatEmbedUrl(url) {
@@ -190,12 +271,16 @@
190
 
191
  socket = new WebSocket(wsUrl);
192
  socket.binaryType = "blob";
 
193
 
194
  socket.onopen = () => {
195
  statusWrapper.classList.remove("status-stopped");
196
  statusText.innerText = "已連線 (運作中)";
197
  appendConsole("[系統] 連線成功,學生專案已啟動。\n");
198
- setLoading(true, "專案執行中…", `最長執行時間約 ${Math.round(sessionTimeoutSec/60)} 分鐘`);
 
 
 
199
  // Hide loading once first frame or after short delay
200
  setTimeout(() => setLoading(false), 2500);
201
  };
@@ -217,8 +302,12 @@
217
  const data = JSON.parse(event.data);
218
  if (data.type === "log") {
219
  appendConsole(data.data);
 
 
 
 
220
  } else if (data.type === "status") {
221
- if (data.data === "stopped" || data.data === "timeout" || data.data === "error" || data.data === "busy") {
222
  setStoppedStatus(data.data);
223
  setLoading(false);
224
  } else if (data.data === "running") {
@@ -233,8 +322,12 @@
233
  };
234
 
235
  socket.onclose = () => {
236
- setStoppedStatus();
237
- appendConsole("\n[系統] 與伺服器的通訊中斷。\n");
 
 
 
 
238
  };
239
 
240
  socket.onerror = () => {
@@ -243,10 +336,20 @@
243
  }
244
 
245
  function setStoppedStatus(reason) {
 
246
  statusWrapper.classList.add("status-stopped");
247
- const labels = { stopped: "已停止", timeout: "逾時結束", error: "錯誤", busy: "名額已滿" };
 
 
 
 
 
 
 
248
  statusText.innerText = labels[reason] || "已停止";
249
  stopWebcamStream();
 
 
250
  setLoading(false);
251
  }
252
 
@@ -463,8 +566,10 @@
463
  }
464
 
465
  window.onload = initViewport;
 
466
  window.onbeforeunload = () => {
467
  stopWebcamStream();
 
468
  if (socket) socket.close();
469
  };
470
 
@@ -495,4 +600,3 @@
495
  if (terminalInputField) terminalInputField.focus();
496
  });
497
  }
498
-
 
11
  const appetizeKey = cfg.appetizeKey || "";
12
  const viewOnly = !!cfg.viewOnly;
13
  const sessionTimeoutSec = cfg.sessionTimeoutSec || 900;
14
+ const idleWarningSec = cfg.idleWarningSec || 600;
15
+ const idleTimeoutSec = cfg.idleTimeoutSec || 720;
16
+ const heartbeatIntervalSec = cfg.heartbeatIntervalSec || 20;
17
 
18
  const viewportFrame = document.getElementById("viewport-frame");
19
  const focusPrompt = document.getElementById("focus-prompt-el");
 
24
  const webcamVideo = document.getElementById("webcam-video");
25
  const webcamCanvas = document.getElementById("webcam-canvas");
26
  const webcamCtx = webcamCanvas.getContext("2d");
27
+ const idleDialog = document.getElementById("idle-dialog");
28
+ const idleCountdown = document.getElementById("idle-countdown");
29
+ const btnContinueSession = document.getElementById("btn-continue-session");
30
 
31
  let socket = null;
32
  let oldUrl = null;
 
34
  let webcamInterval = null;
35
  let isWebcamStreaming = false;
36
  let isFocused = false;
37
+ let heartbeatTimer = null;
38
+ let idleCountdownTimer = null;
39
+ let idleDeadline = 0;
40
+ let lastActivitySentAt = 0;
41
+ let stopReason = null;
42
+
43
+ function sendSocketMessage(message) {
44
+ if (!socket || socket.readyState !== WebSocket.OPEN) return false;
45
+ socket.send(JSON.stringify(message));
46
+ return true;
47
+ }
48
+
49
+ function sendActivity(force = false) {
50
+ const now = Date.now();
51
+ if (!force && now - lastActivitySentAt < 10000) return;
52
+ if (sendSocketMessage({ type: "activity" })) {
53
+ lastActivitySentAt = now;
54
+ hideIdleWarning();
55
+ }
56
+ }
57
+
58
+ function startHeartbeat() {
59
+ stopHeartbeat();
60
+ heartbeatTimer = setInterval(() => {
61
+ sendSocketMessage({ type: "heartbeat" });
62
+ }, heartbeatIntervalSec * 1000);
63
+ }
64
+
65
+ function stopHeartbeat() {
66
+ if (heartbeatTimer) {
67
+ clearInterval(heartbeatTimer);
68
+ heartbeatTimer = null;
69
+ }
70
+ }
71
+
72
+ function formatCountdown(seconds) {
73
+ const safeSeconds = Math.max(0, Math.ceil(seconds));
74
+ const minutes = Math.floor(safeSeconds / 60);
75
+ return `${minutes}:${String(safeSeconds % 60).padStart(2, "0")}`;
76
+ }
77
+
78
+ function showIdleWarning(remainingSec) {
79
+ if (!idleDialog) return;
80
+ idleDeadline = Date.now() + Math.max(0, remainingSec) * 1000;
81
+ idleDialog.hidden = false;
82
+ updateIdleCountdown();
83
+ if (idleCountdownTimer) clearInterval(idleCountdownTimer);
84
+ idleCountdownTimer = setInterval(updateIdleCountdown, 250);
85
+ if (btnContinueSession) btnContinueSession.focus();
86
+ }
87
+
88
+ function updateIdleCountdown() {
89
+ if (!idleCountdown) return;
90
+ idleCountdown.textContent = formatCountdown((idleDeadline - Date.now()) / 1000);
91
+ }
92
+
93
+ function hideIdleWarning() {
94
+ if (idleDialog) idleDialog.hidden = true;
95
+ if (idleCountdownTimer) {
96
+ clearInterval(idleCountdownTimer);
97
+ idleCountdownTimer = null;
98
+ }
99
+ }
100
+
101
+ function setupActivityTracking() {
102
+ ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"].forEach((eventName) => {
103
+ window.addEventListener(eventName, () => sendActivity(), { passive: true });
104
+ });
105
+ document.addEventListener("visibilitychange", () => {
106
+ if (!document.hidden) sendActivity(true);
107
+ });
108
+ if (btnContinueSession) {
109
+ btnContinueSession.addEventListener("click", () => sendActivity(true));
110
+ }
111
+ }
112
 
113
  // Auto-transform Scratch URLs into embed links
114
  function formatEmbedUrl(url) {
 
271
 
272
  socket = new WebSocket(wsUrl);
273
  socket.binaryType = "blob";
274
+ stopReason = null;
275
 
276
  socket.onopen = () => {
277
  statusWrapper.classList.remove("status-stopped");
278
  statusText.innerText = "已連線 (運作中)";
279
  appendConsole("[系統] 連線成功,學生專案已啟動。\n");
280
+ appendConsole(`[系統] 閒置 ${Math.round(idleWarningSec/60)} 分鐘會提醒,${Math.round(idleTimeoutSec/60)} 分鐘後自動釋放名額。\n`);
281
+ setLoading(true, "專案執行中…", `閒置 ${Math.round(idleTimeoutSec/60)} 分鐘會自動結束`);
282
+ sendActivity(true);
283
+ startHeartbeat();
284
  // Hide loading once first frame or after short delay
285
  setTimeout(() => setLoading(false), 2500);
286
  };
 
302
  const data = JSON.parse(event.data);
303
  if (data.type === "log") {
304
  appendConsole(data.data);
305
+ } else if (data.type === "idle_warning") {
306
+ showIdleWarning(data.remaining_sec || 0);
307
+ } else if (data.type === "idle_resumed") {
308
+ hideIdleWarning();
309
  } else if (data.type === "status") {
310
+ if (["stopped", "timeout", "idle_timeout", "connection_timeout", "error", "busy"].includes(data.data)) {
311
  setStoppedStatus(data.data);
312
  setLoading(false);
313
  } else if (data.data === "running") {
 
322
  };
323
 
324
  socket.onclose = () => {
325
+ stopHeartbeat();
326
+ hideIdleWarning();
327
+ setStoppedStatus(stopReason);
328
+ if (!stopReason) {
329
+ appendConsole("\n[系統] 與伺服器的通訊中斷。\n");
330
+ }
331
  };
332
 
333
  socket.onerror = () => {
 
336
  }
337
 
338
  function setStoppedStatus(reason) {
339
+ if (reason) stopReason = reason;
340
  statusWrapper.classList.add("status-stopped");
341
+ const labels = {
342
+ stopped: "已停止",
343
+ timeout: "已達執行上限",
344
+ idle_timeout: "閒置結束",
345
+ connection_timeout: "連線已離開",
346
+ error: "錯誤",
347
+ busy: "名額已滿"
348
+ };
349
  statusText.innerText = labels[reason] || "已停止";
350
  stopWebcamStream();
351
+ stopHeartbeat();
352
+ hideIdleWarning();
353
  setLoading(false);
354
  }
355
 
 
566
  }
567
 
568
  window.onload = initViewport;
569
+ setupActivityTracking();
570
  window.onbeforeunload = () => {
571
  stopWebcamStream();
572
+ stopHeartbeat();
573
  if (socket) socket.close();
574
  };
575
 
 
600
  if (terminalInputField) terminalInputField.focus();
601
  });
602
  }
 
templates/player.html CHANGED
@@ -136,6 +136,15 @@
136
  </div>
137
  </div>
138
 
 
 
 
 
 
 
 
 
 
139
  <!-- Hidden audio/video capturing items -->
140
  <video id="webcam-video" autoplay playsinline width="640" height="480"></video>
141
  <canvas id="webcam-canvas" width="640" height="480"></canvas>
@@ -153,6 +162,9 @@
153
  appetizeKey: {{ appetize_key|tojson }},
154
  viewOnly: {{ view_only|default(false)|tojson }},
155
  sessionTimeoutSec: {{ session_timeout_sec|default(900) }},
 
 
 
156
  maxConcurrentRunners: {{ max_concurrent_runners|default(3) }}
157
  };
158
  </script>
 
136
  </div>
137
  </div>
138
 
139
+ <div class="idle-dialog-backdrop" id="idle-dialog" role="dialog" aria-modal="true" aria-labelledby="idle-dialog-title" hidden>
140
+ <div class="idle-dialog-card">
141
+ <div class="idle-dialog-icon" aria-hidden="true">⏳</div>
142
+ <h2 id="idle-dialog-title">還在觀看嗎?</h2>
143
+ <p>這個作品已一段時間沒有操作。為了讓其他家長也能使用展示名額,系統將在 <strong id="idle-countdown">2:00</strong> 後自動結束。</p>
144
+ <button type="button" id="btn-continue-session">繼續觀看</button>
145
+ </div>
146
+ </div>
147
+
148
  <!-- Hidden audio/video capturing items -->
149
  <video id="webcam-video" autoplay playsinline width="640" height="480"></video>
150
  <canvas id="webcam-canvas" width="640" height="480"></canvas>
 
162
  appetizeKey: {{ appetize_key|tojson }},
163
  viewOnly: {{ view_only|default(false)|tojson }},
164
  sessionTimeoutSec: {{ session_timeout_sec|default(900) }},
165
+ idleWarningSec: {{ idle_warning_sec|default(600) }},
166
+ idleTimeoutSec: {{ idle_timeout_sec|default(720) }},
167
+ heartbeatIntervalSec: {{ heartbeat_interval_sec|default(20) }},
168
  maxConcurrentRunners: {{ max_concurrent_runners|default(3) }}
169
  };
170
  </script>