aqua-listen: export panel with copy, textarea, share (Android Chrome fix) #50
4 changed files with 48 additions and 18 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# aqua-listen
|
||||
|
||||
A listening-test kit: a folder of sound clips and a manifest become **one HTML file** you can send to a listener. They open it, listen, say keep / fix / drop for each sound, and send back two small files.
|
||||
A listening-test kit: a folder of sound clips and a manifest become **one HTML file** you can send to a listener. They open it, listen, say keep / fix / drop for each sound, and send back your answers.
|
||||
|
||||
**Status:** the page was driven in headless Chromium (consent gate, autosave across reload, export, zero network requests) and `node test.mjs` covers validation, ordering and the export shape. Nobody has listened with it yet, and the headless browser played nothing audibly; real playback on a phone or other browser is untried.
|
||||
**Status:** the page was driven in headless Chromium, desktop and a mobile-emulated context (touch, 412 px viewport, Android Chrome user agent; `MOBILE=1 node smoke.mjs`): consent gate, autosave across reload, the export panel with its copy button and read-only textarea, zero network requests. It has not run on a real Android device and `node test.mjs` covers validation, ordering and the export shape. Nobody has listened with it yet, and the headless browser played nothing audibly; real playback on a phone or other browser is untried.
|
||||
|
||||
## For the listener (no technical knowledge needed)
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ A listening-test kit: a folder of sound clips and a manifest become **one HTML f
|
|||
5. Some sounds come as a pair, **A** and **B**. Rate both, then say which you prefer, or that there is no difference.
|
||||
6. The sounds are in a random order and their names are hidden on purpose. Please don't hunt for them.
|
||||
7. Your answers save themselves in your browser as you go. You can close the tab and come back to the same file later.
|
||||
8. When done, press **Export**. Two files land in your downloads: `verdict.json` and `verdict.md`. Send both to whoever gave you the page. That is the only way anything leaves your computer.
|
||||
8. When done, press **Export my answers**. Your answers appear in a text box. Press **Copy answers (JSON)** and paste them into a message to whoever gave you the page (or press **Share**, or **Select all** and use your phone's Copy). **Download verdict.json** and **Download verdict.md** are separate buttons, one file per tap, for browsers that allow downloads. Nothing leaves your computer except what you copy, share or download.
|
||||
|
||||
Privacy: the page has no network access at all (the browser is told to refuse it), no account, no tracking. "Erase my answers" at the bottom wipes what the page saved.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { resolve } from "node:path";
|
|||
const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_CORE ?? "playwright-core");
|
||||
const file = resolve(process.argv[2]);
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME || undefined });
|
||||
const ctx = await browser.newContext({ acceptDownloads: true }), page = await ctx.newPage();
|
||||
// MOBILE=1 drives an Android-Chrome-shaped context (touch, small viewport, mobile UA) with no downloads accepted.
|
||||
const mobile = !!process.env.MOBILE;
|
||||
const ctx = await browser.newContext(mobile
|
||||
? { acceptDownloads: false, viewport: { width: 412, height: 915 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2.6, permissions: ["clipboard-read", "clipboard-write"],
|
||||
userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36" }
|
||||
: { acceptDownloads: true, permissions: ["clipboard-read", "clipboard-write"] }), page = await ctx.newPage();
|
||||
const requests = [], errors = [];
|
||||
page.on("request", (r) => { if (!r.url().startsWith("file:") && !r.url().startsWith("blob:") && !r.url().startsWith("data:")) requests.push(r.url()); });
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
|
@ -24,15 +29,21 @@ await first.locator("input[value=fix]").check(); await first.locator("input[type
|
|||
await page.reload();
|
||||
check(await page.locator("section.card").first().locator("input[value=fix]").isChecked(), "answers survive a reload (localStorage autosave)");
|
||||
check((await page.locator("section.card").first().locator("input[type=text]").inputValue()) === "a bit thin", "reason survives a reload");
|
||||
const got = [];
|
||||
page.on("download", (d) => got.push(d));
|
||||
await page.click("button:has-text('Export')");
|
||||
for (let i = 0; i < 50 && got.length < 2; i++) await page.waitForTimeout(100);
|
||||
const names = got.map((d) => d.suggestedFilename()).sort();
|
||||
check(names.join() === "verdict.json,verdict.md", "export downloads verdict.json and verdict.md");
|
||||
const v = JSON.parse(readFileSync(await got.find((d) => d.suggestedFilename() === "verdict.json").path(), "utf8"));
|
||||
check(v.listener === "Tester" && v.device === "headphones" && v.clips[0].verdict === "fix" && v.clips[0].landed === 4 && v.clips[0].device === "headphones", "verdict.json carries the answers");
|
||||
await page.click("#export");
|
||||
const text = await page.inputValue("#export-text");
|
||||
check(await page.locator("#export-text").evaluate((e) => e.readOnly), "export textarea is read-only");
|
||||
const v = JSON.parse(text);
|
||||
check(v.listener === "Tester" && v.device === "headphones" && v.clips[0].verdict === "fix" && v.clips[0].landed === 4 && v.clips[0].device === "headphones", "textarea carries the verdict JSON");
|
||||
check(v.consent === true && v.clips.length === total, "consent recorded, every clip present");
|
||||
await page.click("#copy-json");
|
||||
check((await page.evaluate(() => navigator.clipboard.readText())) === text, "Copy button puts the full JSON on the clipboard");
|
||||
// http-Android shape: no async clipboard API at all, so only the execCommand path can copy
|
||||
await page.evaluate(() => { Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); window.__copied = null; document.execCommand = (c) => { window.__copied = document.activeElement.value; return c === "copy"; }; });
|
||||
await page.click("#copy-json");
|
||||
check((await page.textContent("#export-msg")).startsWith("Copied") && (await page.evaluate(() => window.__copied)) === text, "without the clipboard API, the execCommand fallback copies the full JSON");
|
||||
await page.reload();
|
||||
await page.click("#export");
|
||||
check((await page.inputValue("#export-text")).includes('"verdict": "fix"'), "reload loses nothing: export after reload still has the answers");
|
||||
check(requests.length === 0, `no network requests (${requests.join(", ")})`);
|
||||
check(errors.length === 0, `no page errors (${errors.join("; ")})`);
|
||||
await browser.close();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
h("ul", {},
|
||||
h("li", {}, "This page works on its own. It talks to no server, has no account and no tracking, and sends nothing anywhere."),
|
||||
h("li", {}, "Your answers are saved in this browser only, so you can close the tab and come back. Clearing your browser data erases them."),
|
||||
h("li", {}, "Nothing leaves your computer until you press Export, which saves two files. Sending those files to the person who gave you this page is your choice."),
|
||||
h("li", {}, "Nothing leaves your computer until you press Export, which shows your answers as text to copy, share or save. Sending those files to the person who gave you this page is your choice."),
|
||||
h("li", {}, "You can stay anonymous. A name or nickname is optional.")),
|
||||
h("p", {}, h("label", {}, box, " I understand, and I'm happy to take part.")),
|
||||
h("p", {}, h("label", {}, "Name or nickname (optional) ", h("input", { type: "text", id: "alias", maxlength: "60" }))),
|
||||
|
|
@ -88,20 +88,38 @@
|
|||
h("div", { class: "bar" }, h("div", { class: "row" }, dev, prog)),
|
||||
...cards,
|
||||
h("div", { class: "card" }, h("h2", {}, "Finished?"), notes,
|
||||
h("p", { class: "hint" }, "Export saves verdict.json and verdict.md to your downloads. Unanswered sounds are exported as blank."),
|
||||
h("div", { class: "row" }, h("button", { onclick: exportAll }, "Export verdict.json + verdict.md"), h("button", { class: "quiet", onclick: reset }, "Erase my answers"))));
|
||||
h("p", { class: "hint" }, "Export shows your answers as text you can copy, share or download. Unanswered sounds are exported as blank. Your answers stay saved in this browser until you erase them."),
|
||||
h("div", { class: "row" }, h("button", { id: "export", onclick: exportAll }, "Export my answers"), h("button", { class: "quiet", onclick: reset }, "Erase my answers")), h("div", { id: "export-panel" })));
|
||||
progress();
|
||||
}
|
||||
|
||||
function progress() { const e = document.getElementById("progress"); if (e) e.textContent = `${kit.manifest.clips.filter((c) => answered(c.id)).length} of ${kit.manifest.clips.length} answered`; }
|
||||
// Export is a visible panel, not a silent download: Android Chrome blocks the second of two scripted downloads and
|
||||
// gives no sign, so the verdict is also shown in a read-only textarea with copy, share and one-download-per-tap buttons.
|
||||
function verdictNow() { return L.buildVerdict(kit.manifest, order, state, new Date().toISOString()); }
|
||||
function download(name, text, type) {
|
||||
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type })), download: name });
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
}
|
||||
async function copyText(text, area) {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch (e) { /* no clipboard API or not allowed: fall through */ }
|
||||
try { area.focus(); area.select(); area.setSelectionRange(0, text.length); return document.execCommand("copy"); } catch (e) { return false; }
|
||||
}
|
||||
function exportAll() {
|
||||
const v = L.buildVerdict(kit.manifest, order, state, new Date().toISOString());
|
||||
download("verdict.json", JSON.stringify(v, null, 2) + "\n", "application/json");
|
||||
download("verdict.md", L.verdictMarkdown(v), "text/markdown");
|
||||
const v = verdictNow(), json = JSON.stringify(v, null, 2) + "\n", md = L.verdictMarkdown(v);
|
||||
const area = h("textarea", { id: "export-text", rows: "10", readonly: true, "aria-label": "Your answers as verdict.json" }, json);
|
||||
const msg = h("p", { class: "hint", id: "export-msg", role: "status" }, "Your answers are below. Copy them and paste them into a message to whoever sent you this page.");
|
||||
const say = (t) => { msg.textContent = t; };
|
||||
const buttons = [
|
||||
h("button", { id: "copy-json", onclick: async () => say((await copyText(json, area)) ? "Copied verdict.json. Paste it into your message." : "Copy was blocked. The text box is selected: use your phone's Copy.") }, "Copy answers (JSON)"),
|
||||
h("button", { class: "quiet", id: "copy-md", onclick: async () => { const t = h("textarea", {}, md); document.body.append(t); const ok = await copyText(md, t); t.remove(); say(ok ? "Copied the readable table." : "Copy was blocked."); } }, "Copy as table"),
|
||||
h("button", { class: "quiet", onclick: () => { area.focus(); area.select(); area.setSelectionRange(0, json.length); say("Selected. Use your phone's Copy."); } }, "Select all"),
|
||||
h("button", { class: "quiet", onclick: () => download("verdict.json", json, "application/json") }, "Download verdict.json"),
|
||||
h("button", { class: "quiet", onclick: () => download("verdict.md", md, "text/markdown") }, "Download verdict.md")];
|
||||
if (navigator.share) buttons.splice(2, 0, h("button", { class: "quiet", id: "share", onclick: () => navigator.share({ title: "Listening verdict", text: json }).catch(() => say("Sharing was cancelled or unavailable.")) }, "Share"));
|
||||
const panel = document.getElementById("export-panel");
|
||||
panel.replaceChildren(h("h3", {}, "Your answers"), msg, area, h("div", { class: "row" }, buttons));
|
||||
area.focus(); area.select(); panel.scrollIntoView();
|
||||
}
|
||||
function reset() { if (confirm("Erase every answer you've given in this browser?")) { try { localStorage.removeItem(KEY); } catch (e) {} location.reload(); } }
|
||||
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|||
.bar { position: sticky; top: 0; background: var(--bg); padding: 0.5rem 0; border-bottom: 1px solid var(--line); z-index: 1; }
|
||||
.pair { border: 2px dashed var(--line); border-radius: 10px; padding: 0 0.75rem; margin: 1rem 0; }
|
||||
small, .hint { opacity: 0.75; }
|
||||
textarea[readonly] { font: 0.8rem/1.3 ui-monospace, monospace; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue