Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- 新增 `README.en.md`(英文完整說明)、`SECURITY.md`(漏洞回報、ADMIN_KEY 處理與輪替、QA 會執行 JS);README 開頭放 Live Demo 連結與 45 秒流程 GIF(`docs/demo.gif`)。
- 乾淨環境安裝測試(OrbStack Ubuntu 24.04,照 README 從 clone 到離線 QA、講義、PDF、做法 B 全程):發現 **Node 18 跑不了 Playwright**,需求改為 Node 20+(README 徽章、install.sh 檢查、SKILL.md、engines),疑難排解補上;`build-handout.mjs` 找不到 render 目錄時改成明確錯誤訊息。
- 修正 PR #3/#4 的 Codex 審查意見:數字題中位數偶數筆改取中間兩筆平均(抽出 `worker/src/stats.js` 並加 `node --test` 測試,CI 一併執行);投影片端快速換頁不再重開已離開的題目(控制請求排隊+換頁序號);手機頁回前景時不會多開一條 WebSocket;模擬模式開在加入頁時就顯示模擬人數;`qrcode` 改為 skill 自帶套件,照文件安裝即可跑 `poll-slides.mjs`;README 部署指令改用子 shell,不會停在 `my-poll` 裡;Live Demo 安裝頁改為 Node 20+。
- 投票投影片端:斷線超過 5 秒時,開題/離開的控制請求逾時後會以退避(1、2、4… 秒,上限 15 秒、每次換頁最多 8 次)重送**目前這頁**的狀態,網路恢復(`online` 事件或 WebSocket 重連)時立即重送;換頁後舊的重送作廢,揭曉、清空不重送。也修正斷線時連過兩張非投票頁、後端停在上一題的情況。

## 1.3.0 — 2026-10-18

Expand Down
40 changes: 36 additions & 4 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ <h2>點點看:這是真的能作答的測驗</h2>
<div class="lp-side"><div class="lp-sim">示範模式<br><b>模擬 60 人作答</b><br>正式使用時這裡是加入 QR</div><div class="lp-count"><b class="lp-total">0</b> 人作答</div>
<div class="lp-phones-line"><span class="lp-dot"></span><b class="lp-phones">0</b> 支手機在線</div><div class="lp-net"></div>
<div class="lp-hint">按 A 揭曉</div></div></div>
<aside class="notes">請大家猜一個數字,看分布與中位數後按 A 揭曉。答案 279 KB。單一檔案、拔掉網路照樣能播。數字由 build 時實際量測。(彩排後按 Shift+R 清空這題)</aside></section>
<aside class="notes">請大家猜一個數字,看分布與中位數後按 A 揭曉。答案 281 KB。單一檔案、拔掉網路照樣能播。數字由 build 時實際量測。(彩排後按 Shift+R 清空這題)</aside></section>

<!-- 10 滑桿對比 -->
<section>
Expand Down Expand Up @@ -804,7 +804,7 @@ <h1 data-letters>謝謝觀看</h1>
@keyframes lpPulse{70%{box-shadow:0 0 0 12px rgba(111,207,151,0)}0%{box-shadow:0 0 0 0 rgba(111,207,151,.6)}}
@media (prefers-reduced-motion:reduce){.lp-bar span,.lp-bin-bar span,.lp-answer{transition:none}.lp-live .lp-dot{animation:none}}
</style>
<script>window.POLL_CFG={"sim":60,"polls":{"warmup":{"type":"choice","kicker":"手機即時投票(示範模式)","title":"你平常用什麼做簡報?","stem":"正式使用時,觀眾掃 QR 用手機作答;這裡模擬 60 人作答。作答數穩定後按 A 揭曉,Shift+R 重來。","options":["PowerPoint/Keynote","Google 簡報","HTML(reveal.js 等)","其他"],"answer":2,"explain":"這題沒有標準答案,示範揭曉效果:正式場合時,答案會同步推到每一支手機。"},"size":{"type":"number","kicker":"猜數字(示範模式)","title":"這份 Demo 整份 HTML 有多大?","stem":"reveal.js、講者備註外掛、QR、測驗、投票程式全部內嵌在同一個檔案裡。猜猜看幾 KB?","min":0,"max":5000,"unit":"KB","answer":279,"bins":[[0,99],[100,299],[300,499],[500,999],[1000,1999],[2000,5000]],"explain":"單一檔案、拔掉網路照樣能播。數字由 build 時實際量測。"}},"event":"htmlpptskill Live Demo"};</script>
<script>window.POLL_CFG={"sim":60,"polls":{"warmup":{"type":"choice","kicker":"手機即時投票(示範模式)","title":"你平常用什麼做簡報?","stem":"正式使用時,觀眾掃 QR 用手機作答;這裡模擬 60 人作答。作答數穩定後按 A 揭曉,Shift+R 重來。","options":["PowerPoint/Keynote","Google 簡報","HTML(reveal.js 等)","其他"],"answer":2,"explain":"這題沒有標準答案,示範揭曉效果:正式場合時,答案會同步推到每一支手機。"},"size":{"type":"number","kicker":"猜數字(示範模式)","title":"這份 Demo 整份 HTML 有多大?","stem":"reveal.js、講者備註外掛、QR、測驗、投票程式全部內嵌在同一個檔案裡。猜猜看幾 KB?","min":0,"max":5000,"unit":"KB","answer":281,"bins":[[0,99],[100,299],[300,499],[500,999],[1000,1999],[2000,5000]],"explain":"單一檔案、拔掉網路照樣能播。數字由 build 時實際量測。"}},"event":"htmlpptskill Live Demo"};</script>
<script>/* 手機即時投票:投影片端(講者電腦)。需要 window.POLL_CFG = { url, key, polls },由 scripts/poll-slides.mjs 注入。
進到投票頁自動開題;離開投票頁手機回到「等下一題」。
講者鍵:A 揭曉、Shift+R 清空本題(彩排後用)。網路斷了照樣能按 A 顯示答案。
Expand All @@ -819,6 +819,7 @@ <h1 data-letters>謝謝觀看</h1>
const pollSec = id => document.querySelector(`section[data-poll="${id}"]`);
const results = {};
let current = null, ws = null, wsRetry = 0, poller = null, navSeq = 0;
let navDone = 0, navTry = 0, navTimer = null, navWait = 0, navBad = false; // 後端已確認的換頁序號;換頁重送的退避狀態

/* ---------------------------------------------------------------- 模擬模式 */
const SIM = CFG.sim ? (CFG.sim === true ? 60 : CFG.sim) : 0;
Expand Down Expand Up @@ -897,7 +898,7 @@ <h1 data-letters>謝謝觀看</h1>
if (OFFLINE) return setNet(false, '離線模式:按 A 仍可揭曉');
try { ws = new WebSocket(CFG.url.replace(/^http/, 'ws') + '/ws?role=screen'); }
catch (e) { return startPolling(); }
ws.onopen = () => { wsRetry = 0; setNet(true); stopPolling(); };
ws.onopen = () => { wsRetry = 0; setNet(true); stopPolling(); navResendNow(); };
ws.onmessage = e => { if (e.data !== 'pong') onMessage(JSON.parse(e.data)); };
ws.onclose = () => {
setNet(false, '連線中斷,重試中…(按 A 仍可揭曉)');
Expand All @@ -907,6 +908,28 @@ <h1 data-letters>謝謝觀看</h1>
}
setInterval(() => { if (ws && ws.readyState === 1) ws.send('ping'); }, 25000);

window.addEventListener('online', () => navResendNow());

/* 換頁狀態(開題/離開)沒送到後端時退避重送「目前這頁」的狀態;換頁就作廢舊的,揭曉/清空不重送 */
function navOk(seq) {
if (seq !== navSeq) return;
navDone = seq; navTry = 0; navWait = 0; clearTimeout(navTimer);
if (navBad) { navBad = false; setNet(ws && ws.readyState === 1, '已送到伺服器;即時推播重連中…(按 A 仍可揭曉)'); }
}
function navFail(seq) {
if (seq !== navSeq || OFFLINE) return; // 已換頁:新的那頁自己會送
setNet(false, '連不到投票伺服器,自動重試中(按 A 仍可揭曉)');
clearTimeout(navTimer); navWait = seq; navBad = true;
if (navTry < 8) navTimer = setTimeout(() => navResend(seq), Math.min(15000, 1000 * 2 ** navTry++));
}
function navResend(seq) {
if (seq !== navSeq || navWait !== seq) return;
navWait = 0;
const sec = Reveal.getCurrentSlide(), id = sec && sec.dataset.poll;
if (id) enterPoll(id, seq); else leavePoll(seq);
}
function navResendNow() { if (navWait) { clearTimeout(navTimer); navTry = 0; navResend(navWait); } } // 網路恢復:不必等退避

function startPolling() {
if (poller || OFFLINE) return;
poller = setInterval(async () => {
Expand Down Expand Up @@ -976,11 +999,16 @@ <h1 data-letters>謝謝觀看</h1>
if (!r) return; // 排隊期間已換頁
results[id] = r.results;
} else if (!await control({ poll: id, phase: 'revealed' }, seq)) return; // 回到已揭曉的題:讓手機也顯示答案
navOk(seq);
render(id);
} catch (e) {
setNet(false, '連不到投票伺服器(按 A 仍可揭曉)');
navFail(seq);
}
}
function leavePoll(seq) {
control({ poll: null }, seq).then(r => { if (r) navOk(seq); }, () => navFail(seq));
}

async function reveal(id) {
if (results[id]) results[id].phase = 'revealed';
Expand All @@ -999,8 +1027,12 @@ <h1 data-letters>謝謝觀看</h1>

function onShow(sec) {
const id = sec && sec.dataset.poll, seq = ++navSeq;
navTry = 0; // 每次換頁都有自己的重送次數
if (id) return enterPoll(id, seq);
if (current) { current = null; if (!SIM) control({ poll: null }, seq).catch(() => {}); }
const wasPoll = current; current = null;
if (SIM) return;
if (!wasPoll && navDone === seq - 1) return navOk(seq); // 本來就沒開題、後端也確認過:不用送
leavePoll(seq); // 上一頁的狀態還沒確認(排隊被略過或失敗)也要送
}

// capture 階段攔截,避免和 reveal.js 內建鍵衝突;只在投票頁生效
Expand Down
36 changes: 34 additions & 2 deletions skills/html-slide-deck/references/live-poll/screen/live-poll.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
const pollSec = id => document.querySelector(`section[data-poll="${id}"]`);
const results = {};
let current = null, ws = null, wsRetry = 0, poller = null, navSeq = 0;
let navDone = 0, navTry = 0, navTimer = null, navWait = 0, navBad = false; // 後端已確認的換頁序號;換頁重送的退避狀態

/* ---------------------------------------------------------------- 模擬模式 */
const SIM = CFG.sim ? (CFG.sim === true ? 60 : CFG.sim) : 0;
Expand Down Expand Up @@ -90,7 +91,7 @@
if (OFFLINE) return setNet(false, '離線模式:按 A 仍可揭曉');
try { ws = new WebSocket(CFG.url.replace(/^http/, 'ws') + '/ws?role=screen'); }
catch (e) { return startPolling(); }
ws.onopen = () => { wsRetry = 0; setNet(true); stopPolling(); };
ws.onopen = () => { wsRetry = 0; setNet(true); stopPolling(); navResendNow(); };
ws.onmessage = e => { if (e.data !== 'pong') onMessage(JSON.parse(e.data)); };
ws.onclose = () => {
setNet(false, '連線中斷,重試中…(按 A 仍可揭曉)');
Expand All @@ -100,6 +101,28 @@
}
setInterval(() => { if (ws && ws.readyState === 1) ws.send('ping'); }, 25000);

window.addEventListener('online', () => navResendNow());

/* 換頁狀態(開題/離開)沒送到後端時退避重送「目前這頁」的狀態;換頁就作廢舊的,揭曉/清空不重送 */
function navOk(seq) {
if (seq !== navSeq) return;
navDone = seq; navTry = 0; navWait = 0; clearTimeout(navTimer);
if (navBad) { navBad = false; setNet(ws && ws.readyState === 1, '已送到伺服器;即時推播重連中…(按 A 仍可揭曉)'); }
}
function navFail(seq) {
if (seq !== navSeq || OFFLINE) return; // 已換頁:新的那頁自己會送
setNet(false, '連不到投票伺服器,自動重試中(按 A 仍可揭曉)');
clearTimeout(navTimer); navWait = seq; navBad = true;
if (navTry < 8) navTimer = setTimeout(() => navResend(seq), Math.min(15000, 1000 * 2 ** navTry++));
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the retry budget when the slide changes

When an outage consumes retries on one slide and the presenter then navigates, navTry retains the previous slide's count because it is reset only after success or an online/WebSocket-open event. If it has already reached 8, the new slide's first failed request sets navWait but schedules no retry here; when the browser remains online and the existing WebSocket stays open (for example, a transient HTTP/control-path failure), recovery emits neither event, so the backend can remain on the old poll indefinitely. Reset the counter when starting a new navigation sequence.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 615fa50 修正:onShow 每次換頁都會把 navTry 歸零,所以每一頁各有 8 次重送額度。已用 Playwright 驗證:WebSocket 一直開著、瀏覽器也在線,只有 /api/control 回 500。先在 warmup 用完 8 次重送,再換到非投票頁。修正前,{poll:null} 失敗 1 次之後就不再送,後端停在舊題;修正後會持續退避重送,控制路徑恢復後就送成功,後端 active=null。

}
function navResend(seq) {
if (seq !== navSeq || navWait !== seq) return;
navWait = 0;
const sec = Reveal.getCurrentSlide(), id = sec && sec.dataset.poll;
if (id) enterPoll(id, seq); else leavePoll(seq);
}
function navResendNow() { if (navWait) { clearTimeout(navTimer); navTry = 0; navResend(navWait); } } // 網路恢復:不必等退避

function startPolling() {
if (poller || OFFLINE) return;
poller = setInterval(async () => {
Expand Down Expand Up @@ -169,11 +192,16 @@
if (!r) return; // 排隊期間已換頁
results[id] = r.results;
} else if (!await control({ poll: id, phase: 'revealed' }, seq)) return; // 回到已揭曉的題:讓手機也顯示答案
navOk(seq);
render(id);
} catch (e) {
setNet(false, '連不到投票伺服器(按 A 仍可揭曉)');
navFail(seq);
}
}
function leavePoll(seq) {
control({ poll: null }, seq).then(r => { if (r) navOk(seq); }, () => navFail(seq));
}

async function reveal(id) {
if (results[id]) results[id].phase = 'revealed';
Expand All @@ -192,8 +220,12 @@

function onShow(sec) {
const id = sec && sec.dataset.poll, seq = ++navSeq;
navTry = 0; // 每次換頁都有自己的重送次數
if (id) return enterPoll(id, seq);
if (current) { current = null; if (!SIM) control({ poll: null }, seq).catch(() => {}); }
const wasPoll = current; current = null;
if (SIM) return;
if (!wasPoll && navDone === seq - 1) return navOk(seq); // 本來就沒開題、後端也確認過:不用送
leavePoll(seq); // 上一頁的狀態還沒確認(排隊被略過或失敗)也要送
}

// capture 階段攔截,避免和 reveal.js 內建鍵衝突;只在投票頁生效
Expand Down
Loading