phone-lens 1.1.0 → 1.1.1

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
@@ -268,6 +268,7 @@ window.__ModuleLoader__.load({
268
268
  const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
269
269
  const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
270
270
  const [previewOff, setPreviewOff] = useState(false); // host says the phone's stream stopped (preview off on the phone)
271
+ const [previewOffline, setPreviewOffline] = useState(false); // host flagged the LINK itself dead (wifi drop, no frames AND no keepalive) — not a preview toggle
271
272
  const [settingsOpen, setSettingsOpen] = useState(false); // collapsible settings section under the main card
272
273
  const [appSt, setAppSt] = useState(null); // {saveMode, saveDir, effectiveSaveDir} | null (null = host too old)
273
274
  const [dirDraft, setDirDraft] = useState("");
@@ -341,9 +342,13 @@ window.__ModuleLoader__.load({
341
342
  } else if (m.type === "frame_meta") {
342
343
  if (m.rotation !== undefined) rotRef.current = m.rotation;
343
344
  } else if (m.type === "preview_state") {
344
- // host-derived: the ACTIVE phone's stream stopped (user turned
345
- // preview off in the app) or started again
345
+ // host-derived: the ACTIVE phone's stream stopped — either the
346
+ // user turned preview off in the app (keepalive still alive) or
347
+ // the link itself died (no frames AND no keepalive for 25s →
348
+ // reason:"offline"). Old hosts never send `reason` → legacy
349
+ // behavior is kept bit-for-bit.
346
350
  setPreviewOff(!m.on);
351
+ setPreviewOffline(!m.on && m.reason === "offline");
347
352
  } else if (m.type === "upload_saved") {
348
353
  // folder-only mode: the photo bypassed the composer; local hint
349
354
  setFlashOk(`已保存至 ${m.dir || "指定文件夹"}`);
@@ -387,6 +392,7 @@ window.__ModuleLoader__.load({
387
392
  ws.onclose = () => {
388
393
  setCamOn(false);
389
394
  setPreviewOff(false);
395
+ setPreviewOffline(false);
390
396
  if (!stopped) timer = setTimeout(connect, 2000);
391
397
  };
392
398
  };
@@ -577,11 +583,13 @@ window.__ModuleLoader__.load({
577
583
  { className: "lm-body" },
578
584
  // only show the viewfinder canvas while a camera is actually
579
585
  // streaming; when the phone turned preview off, clear the last
580
- // frame and say so instead of freezing on a stale picture
586
+ // frame and say so instead of freezing on a stale picture.
587
+ // previewOffline = the host proved the LINK died (no frames AND
588
+ // no keepalive 25s): say "connection lost", never "preview off"
581
589
  camOn && !previewOff
582
590
  ? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 })
583
591
  : camOn && previewOff
584
- ? h("div", { className: "lm-preview-off" }, "您在手机端已关闭预览功能")
592
+ ? h("div", { className: "lm-preview-off" }, previewOffline ? "与手机的连接已中断(设备离线)" : "您在手机端已关闭预览功能")
585
593
  : null,
586
594
  h(
587
595
  "div",
@@ -589,12 +597,14 @@ window.__ModuleLoader__.load({
589
597
  h("span", null, camOn && !previewOff && fps > 0 ? `${fps} fps` : "—"),
590
598
  h(
591
599
  "span",
592
- { className: !camOn ? "warn-big" : previewOff ? "warn" : "" },
600
+ { className: !camOn || previewOffline ? "warn-big" : previewOff ? "warn" : "" },
593
601
  !camOn
594
602
  ? "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备"
595
- : previewOff
596
- ? "预览已关闭,在手机端重新开启后恢复"
597
- : "预览中",
603
+ : previewOffline
604
+ ? "连接已断开(手机离线),请检查手机网络;网络恢复后将自动重连"
605
+ : previewOff
606
+ ? "预览已关闭,在手机端重新开启后恢复"
607
+ : "预览中",
598
608
  ),
599
609
  ),
600
610
  // ── version hints (plain colored lines, never dialogs) ──
package/lib/dev.js CHANGED
@@ -553,7 +553,7 @@ async function fetchLatestGiteeApk() {
553
553
  if (!Array.isArray(assets)) return null;
554
554
  for (const asset of assets) {
555
555
  const row = asset;
556
- if (row.name === "app-release.apk" && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
556
+ if (typeof row.name === "string" && row.name.startsWith("app-release.apk") && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
557
557
  }
558
558
  return null;
559
559
  } catch {
@@ -1457,22 +1457,48 @@ async function pruneUploads(dir, max) {
1457
1457
  const CAM_SILENCE_MS = 6e4;
1458
1458
  /**
1459
1459
 
1460
- * How long the ACTIVE device's stream may go silent before views are told the
1460
+ * How long the ACTIVE device's stream may go silent before the hub starts
1461
1461
 
1462
- * preview was turned off. The phone app never announces "preview off" — it
1462
+ * DERIVING its state (see checkPreviewStall). The phone app never announces
1463
1463
 
1464
- * just stops pushing frames (the WS stays up) — so the host derives the state:
1464
+ * "preview off" — it just stops its frame pump while the WS stays up and the
1465
1465
 
1466
- * a live, open camera uplink with no frames for this long means the user
1466
+ * app-level keepalive ping (every 10s, independent of the preview state)
1467
1467
 
1468
- * switched preview off on the phone. Device disconnects are signalled
1468
+ * keeps flowing. So the discriminator is INBOUND TRAFFIC: "frames stalled +
1469
1469
 
1470
- * separately via the `devices` list.
1470
+ * keepalive still arriving" = user turned preview off (confirmed event-side
1471
+
1472
+ * in confirmPreviewOff the moment a ping lands during a stall); "frames
1473
+
1474
+ * stalled + keepalive dead" = the link itself died.
1471
1475
 
1472
1476
  */
1473
1477
  const PREVIEW_STALL_MS = 3e3;
1474
1478
  /**
1475
1479
 
1480
+ * No frames AND no inbound traffic at all (no app ping, no ws pong, no
1481
+
1482
+ * control) for this long = the link is dead, e.g. the phone left the wifi
1483
+
1484
+ * while the OS keeps the TCP socket half-open without a FIN. 25s = 2.5× the
1485
+
1486
+ * app's 10s keepalive period, so ONE lost ping (worst-case inbound gap 20s)
1487
+
1488
+ * can NEVER trip it — offline is only ever declared on the COMBINATION
1489
+
1490
+ * "frames stalled + inbound silent past this floor". Single-condition
1491
+
1492
+ * verdicts are forbidden here: 宁可晚判,不可误判. The 60s CAM_SILENCE_MS
1493
+
1494
+ * terminate below still owns the actual eviction; this only corrects the
1495
+
1496
+ * UI semantics ~35s earlier.
1497
+
1498
+ */
1499
+ const OFFLINE_SILENCE_MS = 25e3;
1500
+ /**
1501
+
1476
1502
  * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
1477
1503
 
1478
1504
  * deviceId), N loopback view downlinks. The "active" device is auto-selected
@@ -1511,8 +1537,26 @@ var ViewHub = class {
1511
1537
  captureTimeoutMs = 6e4;
1512
1538
  /** reqId → settle callback for in-flight camera_idle/camera_resume requests. */
1513
1539
  cameraStateWaiters = new Map();
1514
- /** Whether the active device is confirmed to be streaming (hello/frame seen). */
1515
- previewOn = false;
1540
+ /**
1541
+
1542
+ * Derived stream state of the ACTIVE device, read off the traffic mix:
1543
+
1544
+ * "on" — frames flowing
1545
+
1546
+ * "off" — frames stalled but the app keepalive ping still arrives
1547
+
1548
+ * (hard evidence the link is alive) → user turned preview
1549
+
1550
+ * off on the phone
1551
+
1552
+ * "offline" — frames stalled AND no inbound at all past
1553
+
1554
+ * OFFLINE_SILENCE_MS → the link itself died (wifi drop,
1555
+
1556
+ * half-open TCP), which is NOT "preview closed"
1557
+
1558
+ */
1559
+ previewState = "off";
1516
1560
  stallTimer = null;
1517
1561
  constructor(config$1, log$1) {
1518
1562
  this.config = config$1;
@@ -1569,11 +1613,13 @@ var ViewHub = class {
1569
1613
  });
1570
1614
  ws.on("pong", () => {
1571
1615
  const c = this.cameras.get(deviceId);
1572
- if (c) c.lastSeenAt = Date.now();
1616
+ if (c?.ws !== ws) return;
1617
+ c.lastSeenAt = Date.now();
1573
1618
  });
1574
1619
  ws.on("message", (data, isBinary) => {
1575
1620
  const c = this.cameras.get(deviceId);
1576
- if (c) c.lastSeenAt = Date.now();
1621
+ if (c?.ws !== ws) return;
1622
+ c.lastSeenAt = Date.now();
1577
1623
  if (isBinary) {
1578
1624
  this.ingestFrame(deviceId, data);
1579
1625
  return;
@@ -1659,6 +1705,7 @@ var ViewHub = class {
1659
1705
  break;
1660
1706
  case "ping":
1661
1707
  this.sendControl(deviceId, { type: "pong" });
1708
+ this.confirmPreviewOff(deviceId);
1662
1709
  break;
1663
1710
  case "capture_result":
1664
1711
  if (msg.status !== "taken") {
@@ -1714,37 +1761,98 @@ var ViewHub = class {
1714
1761
  }
1715
1762
  /** Flip the view-side preview state to on (once) when stream activity returns. */
1716
1763
  markPreviewActive() {
1717
- if (this.previewOn) return;
1718
- this.previewOn = true;
1764
+ this.setPreviewState("on");
1765
+ }
1766
+ /**
1767
+
1768
+ * The phone's app-level keepalive ping just arrived (10s cadence, runs
1769
+
1770
+ * regardless of the preview state). If the ACTIVE stream has been stalled
1771
+
1772
+ * past PREVIEW_STALL_MS, that ping is hard evidence the LINK is alive and
1773
+
1774
+ * only the frame pump stopped → "user turned preview off on the phone",
1775
+
1776
+ * NOT a disconnect. It also de-escalates a premature "offline" verdict
1777
+
1778
+ * back to "off" once inbound traffic returns (the consecutive-lost-pings
1779
+
1780
+ * edge case). Only the app-level ping qualifies as the discriminator:
1781
+
1782
+ * ws-pongs fire from the OS network stack even when the app is wedged, and
1783
+
1784
+ * hello announces a stream STARTING, so neither may drive this verdict.
1785
+
1786
+ */
1787
+ confirmPreviewOff(deviceId) {
1788
+ if (deviceId !== this.activeDeviceId) return;
1789
+ const cam = this.cameras.get(deviceId);
1790
+ if (!cam || Date.now() - cam.lastFrameAt <= PREVIEW_STALL_MS) return;
1791
+ this.setPreviewState("off");
1792
+ }
1793
+ /** Single choke point for preview-state transitions + view notification. */
1794
+ setPreviewState(next) {
1795
+ if (this.previewState === next) return;
1796
+ this.previewState = next;
1719
1797
  this.broadcastToViews({
1720
1798
  type: "preview_state",
1721
- on: true
1799
+ on: next === "on",
1800
+ ...next === "offline" ? { reason: "offline" } : {}
1722
1801
  });
1802
+ this.log(next === "offline" ? "warn" : "info", `active preview state → ${next}`);
1723
1803
  }
1724
1804
  /**
1725
1805
 
1726
- * Watchdog: an open, ACTIVE camera uplink that has gone silent means the
1806
+ * Watchdog tick (1s): derives the ACTIVE device's stream state from the
1807
+
1808
+ * traffic mix. The phone never announces "preview off" — the host reads it
1809
+
1810
+ * off the wire:
1811
+
1812
+ *
1813
+
1814
+ * frames flowing → "on" (set in ingestFrame)
1815
+
1816
+ * frames stalled + app ping still arriving → "off" (flipped the moment
1727
1817
 
1728
- * user turned preview off on the phone (the app keeps the WS while only
1818
+ * a ping lands during a stall,
1729
1819
 
1730
- * stopping its frame pump). Tell views so they can clear the stale frame.
1820
+ * in confirmPreviewOff)
1731
1821
 
1732
- * Disconnected devices are NOT handled here — the `devices` broadcast owns
1822
+ * frames stalled + NO inbound at all past
1733
1823
 
1734
- * the offline state on the view side.
1824
+ * OFFLINE_SILENCE_MS (2.5 keepalive periods) → "offline" (half-open link:
1825
+
1826
+ * wifi drop — NOT "preview
1827
+
1828
+ * closed"; this is the bug
1829
+
1830
+ * where a dead wifi used to
1831
+
1832
+ * render as "preview off")
1833
+
1834
+ *
1835
+
1836
+ * Between 3s and 25s of stall the hub deliberately says NOTHING (the view
1837
+
1838
+ * keeps the last frame): a wifi drop and a preview-off look identical for
1839
+
1840
+ * the first seconds, and guessing early is exactly the misjudgment this
1841
+
1842
+ * watchdog must not make. 宁可晚判,不可误判. The 60s CAM_SILENCE_MS
1843
+
1844
+ * terminate in pingAll still owns the actual eviction; this only fixes
1845
+
1846
+ * the UI semantics ~35s earlier.
1735
1847
 
1736
1848
  */
1737
1849
  checkPreviewStall() {
1738
- if (!this.previewOn) return;
1739
1850
  const active = this.activeCam();
1740
1851
  if (!active || active.ws.readyState !== active.ws.OPEN) return;
1741
- if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;
1742
- this.previewOn = false;
1743
- this.broadcastToViews({
1744
- type: "preview_state",
1745
- on: false
1746
- });
1747
- this.log("info", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);
1852
+ const now = Date.now();
1853
+ if (now - active.lastFrameAt <= PREVIEW_STALL_MS) return;
1854
+ if (now - active.lastSeenAt < OFFLINE_SILENCE_MS) return;
1855
+ this.setPreviewState("offline");
1748
1856
  }
1749
1857
  attachView(ws, hooks = {}) {
1750
1858
  this.views.add(ws);
@@ -1781,9 +1889,10 @@ var ViewHub = class {
1781
1889
  this.broadcastDevicesTo(ws);
1782
1890
  ws.send(JSON.stringify({
1783
1891
  type: "preview_state",
1784
- on: this.previewOn
1892
+ on: this.previewState === "on",
1893
+ ...this.previewState === "offline" ? { reason: "offline" } : {}
1785
1894
  }));
1786
- if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1895
+ if (this.previewState === "on" && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1787
1896
  }
1788
1897
  viewCount() {
1789
1898
  return this.views.size;
@@ -1813,7 +1922,7 @@ var ViewHub = class {
1813
1922
  }
1814
1923
  pushActiveFrameToViews() {
1815
1924
  const active = this.activeCam();
1816
- if (this.previewOn && active?.lastFrame) {
1925
+ if (this.previewState === "on" && active?.lastFrame) {
1817
1926
  for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
1818
1927
  }
1819
1928
  if (active) this.broadcastToViews({
package/lib/index.js CHANGED
@@ -621,7 +621,7 @@ async function fetchLatestGiteeApk() {
621
621
  if (!Array.isArray(assets)) return null;
622
622
  for (const asset of assets) {
623
623
  const row = asset;
624
- if (row.name === "app-release.apk" && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
624
+ if (typeof row.name === "string" && row.name.startsWith("app-release.apk") && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
625
625
  }
626
626
  return null;
627
627
  } catch {
@@ -1525,22 +1525,48 @@ async function pruneUploads(dir, max) {
1525
1525
  const CAM_SILENCE_MS = 6e4;
1526
1526
  /**
1527
1527
 
1528
- * How long the ACTIVE device's stream may go silent before views are told the
1528
+ * How long the ACTIVE device's stream may go silent before the hub starts
1529
1529
 
1530
- * preview was turned off. The phone app never announces "preview off" — it
1530
+ * DERIVING its state (see checkPreviewStall). The phone app never announces
1531
1531
 
1532
- * just stops pushing frames (the WS stays up) — so the host derives the state:
1532
+ * "preview off" — it just stops its frame pump while the WS stays up and the
1533
1533
 
1534
- * a live, open camera uplink with no frames for this long means the user
1534
+ * app-level keepalive ping (every 10s, independent of the preview state)
1535
1535
 
1536
- * switched preview off on the phone. Device disconnects are signalled
1536
+ * keeps flowing. So the discriminator is INBOUND TRAFFIC: "frames stalled +
1537
1537
 
1538
- * separately via the `devices` list.
1538
+ * keepalive still arriving" = user turned preview off (confirmed event-side
1539
+
1540
+ * in confirmPreviewOff the moment a ping lands during a stall); "frames
1541
+
1542
+ * stalled + keepalive dead" = the link itself died.
1539
1543
 
1540
1544
  */
1541
1545
  const PREVIEW_STALL_MS = 3e3;
1542
1546
  /**
1543
1547
 
1548
+ * No frames AND no inbound traffic at all (no app ping, no ws pong, no
1549
+
1550
+ * control) for this long = the link is dead, e.g. the phone left the wifi
1551
+
1552
+ * while the OS keeps the TCP socket half-open without a FIN. 25s = 2.5× the
1553
+
1554
+ * app's 10s keepalive period, so ONE lost ping (worst-case inbound gap 20s)
1555
+
1556
+ * can NEVER trip it — offline is only ever declared on the COMBINATION
1557
+
1558
+ * "frames stalled + inbound silent past this floor". Single-condition
1559
+
1560
+ * verdicts are forbidden here: 宁可晚判,不可误判. The 60s CAM_SILENCE_MS
1561
+
1562
+ * terminate below still owns the actual eviction; this only corrects the
1563
+
1564
+ * UI semantics ~35s earlier.
1565
+
1566
+ */
1567
+ const OFFLINE_SILENCE_MS = 25e3;
1568
+ /**
1569
+
1544
1570
  * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
1545
1571
 
1546
1572
  * deviceId), N loopback view downlinks. The "active" device is auto-selected
@@ -1579,8 +1605,26 @@ var ViewHub = class {
1579
1605
  captureTimeoutMs = 6e4;
1580
1606
  /** reqId → settle callback for in-flight camera_idle/camera_resume requests. */
1581
1607
  cameraStateWaiters = new Map();
1582
- /** Whether the active device is confirmed to be streaming (hello/frame seen). */
1583
- previewOn = false;
1608
+ /**
1609
+
1610
+ * Derived stream state of the ACTIVE device, read off the traffic mix:
1611
+
1612
+ * "on" — frames flowing
1613
+
1614
+ * "off" — frames stalled but the app keepalive ping still arrives
1615
+
1616
+ * (hard evidence the link is alive) → user turned preview
1617
+
1618
+ * off on the phone
1619
+
1620
+ * "offline" — frames stalled AND no inbound at all past
1621
+
1622
+ * OFFLINE_SILENCE_MS → the link itself died (wifi drop,
1623
+
1624
+ * half-open TCP), which is NOT "preview closed"
1625
+
1626
+ */
1627
+ previewState = "off";
1584
1628
  stallTimer = null;
1585
1629
  constructor(config, log) {
1586
1630
  this.config = config;
@@ -1637,11 +1681,13 @@ var ViewHub = class {
1637
1681
  });
1638
1682
  ws.on("pong", () => {
1639
1683
  const c = this.cameras.get(deviceId);
1640
- if (c) c.lastSeenAt = Date.now();
1684
+ if (c?.ws !== ws) return;
1685
+ c.lastSeenAt = Date.now();
1641
1686
  });
1642
1687
  ws.on("message", (data, isBinary) => {
1643
1688
  const c = this.cameras.get(deviceId);
1644
- if (c) c.lastSeenAt = Date.now();
1689
+ if (c?.ws !== ws) return;
1690
+ c.lastSeenAt = Date.now();
1645
1691
  if (isBinary) {
1646
1692
  this.ingestFrame(deviceId, data);
1647
1693
  return;
@@ -1727,6 +1773,7 @@ var ViewHub = class {
1727
1773
  break;
1728
1774
  case "ping":
1729
1775
  this.sendControl(deviceId, { type: "pong" });
1776
+ this.confirmPreviewOff(deviceId);
1730
1777
  break;
1731
1778
  case "capture_result":
1732
1779
  if (msg.status !== "taken") {
@@ -1782,37 +1829,98 @@ var ViewHub = class {
1782
1829
  }
1783
1830
  /** Flip the view-side preview state to on (once) when stream activity returns. */
1784
1831
  markPreviewActive() {
1785
- if (this.previewOn) return;
1786
- this.previewOn = true;
1832
+ this.setPreviewState("on");
1833
+ }
1834
+ /**
1835
+
1836
+ * The phone's app-level keepalive ping just arrived (10s cadence, runs
1837
+
1838
+ * regardless of the preview state). If the ACTIVE stream has been stalled
1839
+
1840
+ * past PREVIEW_STALL_MS, that ping is hard evidence the LINK is alive and
1841
+
1842
+ * only the frame pump stopped → "user turned preview off on the phone",
1843
+
1844
+ * NOT a disconnect. It also de-escalates a premature "offline" verdict
1845
+
1846
+ * back to "off" once inbound traffic returns (the consecutive-lost-pings
1847
+
1848
+ * edge case). Only the app-level ping qualifies as the discriminator:
1849
+
1850
+ * ws-pongs fire from the OS network stack even when the app is wedged, and
1851
+
1852
+ * hello announces a stream STARTING, so neither may drive this verdict.
1853
+
1854
+ */
1855
+ confirmPreviewOff(deviceId) {
1856
+ if (deviceId !== this.activeDeviceId) return;
1857
+ const cam = this.cameras.get(deviceId);
1858
+ if (!cam || Date.now() - cam.lastFrameAt <= PREVIEW_STALL_MS) return;
1859
+ this.setPreviewState("off");
1860
+ }
1861
+ /** Single choke point for preview-state transitions + view notification. */
1862
+ setPreviewState(next) {
1863
+ if (this.previewState === next) return;
1864
+ this.previewState = next;
1787
1865
  this.broadcastToViews({
1788
1866
  type: "preview_state",
1789
- on: true
1867
+ on: next === "on",
1868
+ ...next === "offline" ? { reason: "offline" } : {}
1790
1869
  });
1870
+ this.log(next === "offline" ? "warn" : "info", `active preview state → ${next}`);
1791
1871
  }
1792
1872
  /**
1793
1873
 
1794
- * Watchdog: an open, ACTIVE camera uplink that has gone silent means the
1874
+ * Watchdog tick (1s): derives the ACTIVE device's stream state from the
1875
+
1876
+ * traffic mix. The phone never announces "preview off" — the host reads it
1877
+
1878
+ * off the wire:
1879
+
1880
+ *
1881
+
1882
+ * frames flowing → "on" (set in ingestFrame)
1883
+
1884
+ * frames stalled + app ping still arriving → "off" (flipped the moment
1795
1885
 
1796
- * user turned preview off on the phone (the app keeps the WS while only
1886
+ * a ping lands during a stall,
1797
1887
 
1798
- * stopping its frame pump). Tell views so they can clear the stale frame.
1888
+ * in confirmPreviewOff)
1799
1889
 
1800
- * Disconnected devices are NOT handled here — the `devices` broadcast owns
1890
+ * frames stalled + NO inbound at all past
1801
1891
 
1802
- * the offline state on the view side.
1892
+ * OFFLINE_SILENCE_MS (2.5 keepalive periods) → "offline" (half-open link:
1893
+
1894
+ * wifi drop — NOT "preview
1895
+
1896
+ * closed"; this is the bug
1897
+
1898
+ * where a dead wifi used to
1899
+
1900
+ * render as "preview off")
1901
+
1902
+ *
1903
+
1904
+ * Between 3s and 25s of stall the hub deliberately says NOTHING (the view
1905
+
1906
+ * keeps the last frame): a wifi drop and a preview-off look identical for
1907
+
1908
+ * the first seconds, and guessing early is exactly the misjudgment this
1909
+
1910
+ * watchdog must not make. 宁可晚判,不可误判. The 60s CAM_SILENCE_MS
1911
+
1912
+ * terminate in pingAll still owns the actual eviction; this only fixes
1913
+
1914
+ * the UI semantics ~35s earlier.
1803
1915
 
1804
1916
  */
1805
1917
  checkPreviewStall() {
1806
- if (!this.previewOn) return;
1807
1918
  const active = this.activeCam();
1808
1919
  if (!active || active.ws.readyState !== active.ws.OPEN) return;
1809
- if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;
1810
- this.previewOn = false;
1811
- this.broadcastToViews({
1812
- type: "preview_state",
1813
- on: false
1814
- });
1815
- this.log("info", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);
1920
+ const now = Date.now();
1921
+ if (now - active.lastFrameAt <= PREVIEW_STALL_MS) return;
1922
+ if (now - active.lastSeenAt < OFFLINE_SILENCE_MS) return;
1923
+ this.setPreviewState("offline");
1816
1924
  }
1817
1925
  attachView(ws, hooks = {}) {
1818
1926
  this.views.add(ws);
@@ -1849,9 +1957,10 @@ var ViewHub = class {
1849
1957
  this.broadcastDevicesTo(ws);
1850
1958
  ws.send(JSON.stringify({
1851
1959
  type: "preview_state",
1852
- on: this.previewOn
1960
+ on: this.previewState === "on",
1961
+ ...this.previewState === "offline" ? { reason: "offline" } : {}
1853
1962
  }));
1854
- if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1963
+ if (this.previewState === "on" && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1855
1964
  }
1856
1965
  viewCount() {
1857
1966
  return this.views.size;
@@ -1881,7 +1990,7 @@ var ViewHub = class {
1881
1990
  }
1882
1991
  pushActiveFrameToViews() {
1883
1992
  const active = this.activeCam();
1884
- if (this.previewOn && active?.lastFrame) {
1993
+ if (this.previewState === "on" && active?.lastFrame) {
1885
1994
  for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
1886
1995
  }
1887
1996
  if (active) this.broadcastToViews({