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 +18 -8
- package/lib/dev.js +139 -30
- package/lib/index.js +139 -30
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
345
|
-
// preview off in the app) or
|
|
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
|
-
:
|
|
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
|
|
1460
|
+
* How long the ACTIVE device's stream may go silent before the hub starts
|
|
1461
1461
|
|
|
1462
|
-
*
|
|
1462
|
+
* DERIVING its state (see checkPreviewStall). The phone app never announces
|
|
1463
1463
|
|
|
1464
|
-
* just stops
|
|
1464
|
+
* "preview off" — it just stops its frame pump while the WS stays up and the
|
|
1465
1465
|
|
|
1466
|
-
*
|
|
1466
|
+
* app-level keepalive ping (every 10s, independent of the preview state)
|
|
1467
1467
|
|
|
1468
|
-
*
|
|
1468
|
+
* keeps flowing. So the discriminator is INBOUND TRAFFIC: "frames stalled +
|
|
1469
1469
|
|
|
1470
|
-
*
|
|
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
|
-
/**
|
|
1515
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1718
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
1818
|
+
* a ping lands during a stall,
|
|
1729
1819
|
|
|
1730
|
-
*
|
|
1820
|
+
* in confirmPreviewOff)
|
|
1731
1821
|
|
|
1732
|
-
*
|
|
1822
|
+
* frames stalled + NO inbound at all past
|
|
1733
1823
|
|
|
1734
|
-
*
|
|
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
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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.
|
|
1892
|
+
on: this.previewState === "on",
|
|
1893
|
+
...this.previewState === "offline" ? { reason: "offline" } : {}
|
|
1785
1894
|
}));
|
|
1786
|
-
if (this.
|
|
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.
|
|
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
|
|
1528
|
+
* How long the ACTIVE device's stream may go silent before the hub starts
|
|
1529
1529
|
|
|
1530
|
-
*
|
|
1530
|
+
* DERIVING its state (see checkPreviewStall). The phone app never announces
|
|
1531
1531
|
|
|
1532
|
-
* just stops
|
|
1532
|
+
* "preview off" — it just stops its frame pump while the WS stays up and the
|
|
1533
1533
|
|
|
1534
|
-
*
|
|
1534
|
+
* app-level keepalive ping (every 10s, independent of the preview state)
|
|
1535
1535
|
|
|
1536
|
-
*
|
|
1536
|
+
* keeps flowing. So the discriminator is INBOUND TRAFFIC: "frames stalled +
|
|
1537
1537
|
|
|
1538
|
-
*
|
|
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
|
-
/**
|
|
1583
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1786
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
1886
|
+
* a ping lands during a stall,
|
|
1797
1887
|
|
|
1798
|
-
*
|
|
1888
|
+
* in confirmPreviewOff)
|
|
1799
1889
|
|
|
1800
|
-
*
|
|
1890
|
+
* frames stalled + NO inbound at all past
|
|
1801
1891
|
|
|
1802
|
-
*
|
|
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
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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.
|
|
1960
|
+
on: this.previewState === "on",
|
|
1961
|
+
...this.previewState === "offline" ? { reason: "offline" } : {}
|
|
1853
1962
|
}));
|
|
1854
|
-
if (this.
|
|
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.
|
|
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({
|