phone-lens 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,6 +19,12 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  const NS = "phone-lens";
21
21
  const PORT_KEY = "phoneLens.port";
22
+ // Capture modes (user-selected in the settings panel; stored host-side).
23
+ const SAVE_MODES = [
24
+ { id: "composer", title: "自动注入对话", desc: "拍照后自动放入输入框", flash: "已切换:拍照后自动注入输入框" },
25
+ { id: "both", title: "注入对话并保存", desc: "放入输入框,同时保存到指定文件夹", flash: "已切换:注入对话并保存到文件夹" },
26
+ { id: "folder", title: "仅保存到文件夹", desc: "不放入输入框,只保存到指定电脑文件夹", flash: "已切换:仅保存到文件夹" },
27
+ ];
22
28
  // App download fallbacks (used when /app-qr.json is unavailable on an older host).
23
29
  // Gitee release carrying the newest APK (App unchanged since v0.2.0).
24
30
  const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
@@ -56,6 +62,22 @@ window.__ModuleLoader__.load({
56
62
  .lm-qr a.lm-dl:hover { text-decoration: underline; }
57
63
  .lm-port { display: flex; gap: 6px; align-items: center; font-size: 11px; color: #7d8da0; }
58
64
  .lm-port input { width: 64px; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 3px 6px; font-size: 12px; }
65
+ .lm-settings { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid #232c36; padding-top: 9px; }
66
+ .lm-sethead { display: flex; align-items: center; gap: 8px; }
67
+ .lm-sethead b { font-size: 12px; color: #dfe7ee; font-weight: 600; }
68
+ .lm-setlabel { font-size: 10px; color: #7d8da0; }
69
+ .lm-mode { display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 8px; border: 1px solid #2f3b48; background: #1f2937; cursor: pointer; text-align: left; }
70
+ .lm-mode:hover { background: #28323e; }
71
+ .lm-mode.selected { border-color: #3b7cb5; background: #1d3f61; }
72
+ .lm-mode .dot { width: 9px; height: 9px; border-radius: 50%; border: 2px solid #8fa1b5; flex: none; }
73
+ .lm-mode.selected .dot { border-color: #6cc0ff; background: #6cc0ff; }
74
+ .lm-mode .txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
75
+ .lm-mode .t { font-size: 12px; color: #dfe7ee; }
76
+ .lm-mode .d { font-size: 10px; color: #7d8da0; }
77
+ .lm-setrow { display: flex; gap: 6px; }
78
+ .lm-setrow input { flex: 1; min-width: 0; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 5px 8px; font-size: 11px; }
79
+ .lm-sethint { font-size: 10px; color: #7d8da0; line-height: 1.45; word-break: break-all; }
80
+ .lm-preview-off { height: 220px; display: flex; align-items: center; justify-content: center; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; color: #d9a441; font-size: 12px; text-align: center; padding: 0 12px; }
59
81
  .lm-flash { font-size: 11px; color: #8fa1b5; min-height: 14px; }
60
82
  .lm-flash.ok { color: #58b368; }
61
83
  .lm-flash.err { color: #e5696e; }
@@ -214,8 +236,12 @@ window.__ModuleLoader__.load({
214
236
  const [flash, setFlash] = useState("");
215
237
  const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
216
238
  const [devices, setDevices] = useState([]); // [{id,name,active}]
217
- const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
218
- const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
239
+ const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
240
+ const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
241
+ const [previewOff, setPreviewOff] = useState(false); // host says the phone's stream stopped (preview off on the phone)
242
+ const [settingsOpen, setSettingsOpen] = useState(false); // collapsible settings section under the main card
243
+ const [appSt, setAppSt] = useState(null); // {saveMode, saveDir, effectiveSaveDir} | null (null = host too old)
244
+ const [dirDraft, setDirDraft] = useState("");
219
245
  const canvasRef = useRef(null);
220
246
  const lastQrCode = useRef(null); // last pairing code seen — detects auto rotation for the "已更新" flash
221
247
  const wsRef = useRef(null);
@@ -265,6 +291,13 @@ window.__ModuleLoader__.load({
265
291
  if (m.type === "meta" && m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
266
292
  } else if (m.type === "frame_meta") {
267
293
  if (m.rotation !== undefined) rotRef.current = m.rotation;
294
+ } else if (m.type === "preview_state") {
295
+ // host-derived: the ACTIVE phone's stream stopped (user turned
296
+ // preview off in the app) or started again
297
+ setPreviewOff(!m.on);
298
+ } else if (m.type === "upload_saved") {
299
+ // folder-only mode: the photo bypassed the composer; local hint
300
+ setFlashOk(`已保存至 ${m.dir || "指定文件夹"}`);
268
301
  } else if (m.type === "injected") {
269
302
  if (m.ok) setFlashOk(`已注入会话 ${(m.sessionId || "").slice(0, 8)}…`);
270
303
  else setFlashErr(`已保存到电脑 ✓(未注入:${m.reason || "无活动会话"})`);
@@ -276,7 +309,7 @@ window.__ModuleLoader__.load({
276
309
  stageIntoComposer(hostCtx, port, m.attachmentId)
277
310
  .then(() => {
278
311
  setPending((p) => p.filter((x) => x.id !== m.attachmentId));
279
- setFlashOk("照片已放入对话框输入框,输入文字后发送");
312
+ setFlashOk("照片已注入输入框,输入文字后发送");
280
313
  })
281
314
  .catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
282
315
  } else if (m.type === "devices") {
@@ -301,10 +334,11 @@ window.__ModuleLoader__.load({
301
334
  setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
302
335
  }
303
336
  };
304
- ws.onclose = () => {
305
- setCamOn(false);
306
- if (!stopped) timer = setTimeout(connect, 2000);
307
- };
337
+ ws.onclose = () => {
338
+ setCamOn(false);
339
+ setPreviewOff(false);
340
+ if (!stopped) timer = setTimeout(connect, 2000);
341
+ };
308
342
  };
309
343
  connect();
310
344
  return () => {
@@ -359,6 +393,55 @@ window.__ModuleLoader__.load({
359
393
  }
360
394
  }
361
395
 
396
+ // Manual refresh must FORCE a new pairing code server-side: /qr.json
397
+ // alone keeps returning the still-live code (same QR, same countdown),
398
+ // which read as a dead button. Same recipe as /view.html: ask the host
399
+ // to burn the current code via WS, then fetch the fresh one.
400
+ function manualRefreshQr() {
401
+ const ws = wsRef.current;
402
+ if (ws && ws.readyState === 1) {
403
+ ws.send(JSON.stringify({ type: "refresh_pairing" }));
404
+ setTimeout(() => void refreshQr(true), 150);
405
+ } else {
406
+ void refreshQr(true);
407
+ }
408
+ }
409
+
410
+ // ── settings (capture mode / folder / port) ───────────────────────────
411
+ async function loadAppSt() {
412
+ try {
413
+ const r = await fetch(`${baseUrl}/app-settings`, { cache: "no-store" });
414
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
415
+ const data = await r.json();
416
+ setAppSt(data);
417
+ setDirDraft(data.effectiveSaveDir || data.saveDir || "");
418
+ } catch {
419
+ setAppSt(null); // older host without /app-settings: port-only settings
420
+ }
421
+ }
422
+
423
+ async function saveAppSt(patch) {
424
+ try {
425
+ const r = await fetch(`${baseUrl}/app-settings`, {
426
+ method: "POST",
427
+ headers: { "content-type": "application/json" },
428
+ body: JSON.stringify(patch),
429
+ });
430
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
431
+ const data = await r.json();
432
+ setAppSt(data);
433
+ setDirDraft(data.effectiveSaveDir || data.saveDir || "");
434
+ return true;
435
+ } catch (e) {
436
+ setFlashErr("设置保存失败: " + String(e && e.message || e).slice(0, 60));
437
+ return false;
438
+ }
439
+ }
440
+
441
+ function saveDirDraft() {
442
+ void saveAppSt({ saveDir: dirDraft }).then((ok) => ok && setFlashOk("保存目录已更新"));
443
+ }
444
+
362
445
  // Open the app-download dialog: QR (from the host) + source links.
363
446
  // Falls back to the built-in Gitee/GitHub links when the endpoint is
364
447
  // missing (older host half running an older release).
@@ -442,10 +525,28 @@ window.__ModuleLoader__.load({
442
525
  h(
443
526
  "div",
444
527
  { className: "lm-body" },
445
- // only show the viewfinder canvas when a camera is actually
446
- // streaming; a disconnected phone left a big black rectangle
447
- camOn ? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 }) : null,
448
- h("div", { className: "lm-status" }, h("span", null, fps > 0 ? `${fps} fps` : "—"), h("span", { className: camOn ? "" : "warn-big" }, camOn ? "预览中" : "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备")),
528
+ // only show the viewfinder canvas while a camera is actually
529
+ // streaming; when the phone turned preview off, clear the last
530
+ // frame and say so instead of freezing on a stale picture
531
+ camOn && !previewOff
532
+ ? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 })
533
+ : camOn && previewOff
534
+ ? h("div", { className: "lm-preview-off" }, "您在手机端已关闭预览功能")
535
+ : null,
536
+ h(
537
+ "div",
538
+ { className: "lm-status" },
539
+ h("span", null, camOn && !previewOff && fps > 0 ? `${fps} fps` : "—"),
540
+ h(
541
+ "span",
542
+ { className: !camOn ? "warn-big" : previewOff ? "warn" : "" },
543
+ !camOn
544
+ ? "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备"
545
+ : previewOff
546
+ ? "预览已关闭,在手机端重新开启后恢复"
547
+ : "预览中",
548
+ ),
549
+ ),
449
550
  devices && devices.length > 1
450
551
  ? h(
451
552
  "div",
@@ -481,8 +582,8 @@ window.__ModuleLoader__.load({
481
582
  h(
482
583
  "div",
483
584
  { className: "lm-row" },
484
- h("button", { className: "lm-btn primary", disabled: !camOn, onClick: shoot }, "◉ 拍照并放入输入框"),
485
- h("button", { className: "lm-btn", onClick: () => void refreshQr() }, "↻ 二维码"),
585
+ h("button", { className: "lm-btn primary", disabled: !camOn || previewOff, onClick: shoot }, "◉ 拍照并注入"),
586
+ h("button", { className: "lm-btn", onClick: manualRefreshQr, title: "作废当前配对码并生成新码" }, "↻ 二维码"),
486
587
  ),
487
588
  !camOn
488
589
  ? h(
@@ -512,11 +613,58 @@ window.__ModuleLoader__.load({
512
613
  h("span", { className: `lm-flash${flash.startsWith("已") ? " ok" : ""}` }, flash),
513
614
  h(
514
615
  "div",
515
- { className: "lm-port" },
516
- "接收端口",
517
- h("input", { value: portDraft, onChange: (e) => setPortDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && savePort(), inputMode: "numeric" }),
518
- h("button", { className: "lm-btn", style: { flex: "none", padding: "3px 8px" }, onClick: savePort }, "切换"),
616
+ { className: "lm-row" },
617
+ h("button", { className: "lm-btn", onClick: () => { const next = !settingsOpen; setSettingsOpen(next); if (next) void loadAppSt(); } }, settingsOpen ? "▲ 收起设置" : "⚙ 设置"),
519
618
  ),
619
+ settingsOpen
620
+ ? h(
621
+ "div",
622
+ { className: "lm-settings" },
623
+ appSt
624
+ ? [
625
+ h("div", { className: "lm-setlabel" }, "拍照模式"),
626
+ ...SAVE_MODES.map((m) =>
627
+ h(
628
+ "button",
629
+ {
630
+ key: m.id,
631
+ className: "lm-mode" + (appSt.saveMode === m.id ? " selected" : ""),
632
+ onClick: () => {
633
+ if (appSt.saveMode === m.id) return;
634
+ void saveAppSt({ saveMode: m.id }).then((ok) => ok && setFlashOk(m.flash));
635
+ },
636
+ },
637
+ h("span", { className: "dot" }),
638
+ h(
639
+ "span",
640
+ { className: "txt" },
641
+ h("span", { className: "t" }, m.title),
642
+ h("span", { className: "d" }, m.desc),
643
+ ),
644
+ ),
645
+ ),
646
+ appSt.saveMode !== "composer"
647
+ ? h(
648
+ "div",
649
+ { className: "lm-setrow" },
650
+ h("input", { value: dirDraft, onChange: (e) => setDirDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && saveDirDraft(), placeholder: "保存文件夹的绝对路径(手输或粘贴)" }),
651
+ h("button", { className: "lm-btn", style: { flex: "none" }, onClick: saveDirDraft }, "保存"),
652
+ )
653
+ : null,
654
+ appSt.saveMode !== "composer"
655
+ ? h("div", { className: "lm-sethint" }, `留空路径即恢复默认目录: ${appSt.effectiveSaveDir}`)
656
+ : null,
657
+ ]
658
+ : h("div", { className: "lm-sethint" }, "设置服务不可用(电脑端版本较旧),仅可调整端口。"),
659
+ h(
660
+ "div",
661
+ { className: "lm-port" },
662
+ "接收端口",
663
+ h("input", { value: portDraft, onChange: (e) => setPortDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && savePort(), inputMode: "numeric" }),
664
+ h("button", { className: "lm-btn", style: { flex: "none", padding: "3px 8px" }, onClick: savePort }, "切换"),
665
+ ),
666
+ )
667
+ : null,
520
668
  ),
521
669
  )
522
670
  : null,