Spaces:
Running
Running
Commit ·
8807cbe
1
Parent(s): a22790d
Release idle showcase sessions automatically
Browse files- server.py +129 -21
- static/css/player.css +68 -0
- static/js/player.js +110 -6
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
try:
|
| 735 |
await websocket.close(code=1000)
|
| 736 |
except Exception:
|
| 737 |
pass
|
| 738 |
|
| 739 |
-
|
|
|
|
| 740 |
watchdog = asyncio.create_task(session_watchdog())
|
| 741 |
try:
|
| 742 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
finally:
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 222 |
setStoppedStatus(data.data);
|
| 223 |
setLoading(false);
|
| 224 |
} else if (data.data === "running") {
|
|
@@ -233,8 +322,12 @@
|
|
| 233 |
};
|
| 234 |
|
| 235 |
socket.onclose = () => {
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
};
|
| 239 |
|
| 240 |
socket.onerror = () => {
|
|
@@ -243,10 +336,20 @@
|
|
| 243 |
}
|
| 244 |
|
| 245 |
function setStoppedStatus(reason) {
|
|
|
|
| 246 |
statusWrapper.classList.add("status-stopped");
|
| 247 |
-
const labels = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|