@windypro-rourou/dsh-logcat 0.6.6 → 0.7.0

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
@@ -73,6 +73,87 @@ window.__ModuleLoader__.load({
73
73
  .lc-line.lw-E { border-left: 3px solid #ef5350; }
74
74
  .lc-line.lw-F { border-left: 3px solid #ab47bc; }
75
75
  .lc-screen-img:fullscreen { background: #000; }
76
+
77
+ /* ---------------------------------------------------------------- v2 look */
78
+ /* Layered surface + tighter rhythm; appended last so it overrides the base. */
79
+ .dsh-logcat-panel {
80
+ background: light-dark(#fafbfc, #14171c);
81
+ }
82
+ .lc-header {
83
+ padding: 11px 14px; gap: 10px;
84
+ background: light-dark(rgba(0,0,0,.025), rgba(255,255,255,.025));
85
+ border-bottom: 1px solid light-dark(rgba(0,0,0,.09), rgba(255,255,255,.09));
86
+ }
87
+ .lc-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; margin-right: 2px; }
88
+ .lc-version { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 999px; background: light-dark(rgba(66,133,244,.14), rgba(66,133,244,.2)); color: #4285f4; }
89
+ .lc-close { width: 26px; height: 26px; border-radius: 8px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); background: transparent; color: inherit; cursor: pointer; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; }
90
+ .lc-close:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
91
+ .lc-dot.on { animation: lc-pulse 2s ease-in-out infinite; }
92
+ @keyframes lc-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(76,175,80,.55); } 50% { box-shadow: 0 0 0 5px rgba(76,175,80,0); } }
93
+
94
+ /* chips → soft cards */
95
+ .lc-stats { padding: 7px 14px; gap: 7px; }
96
+ .lc-chip {
97
+ padding: 3px 9px; border-radius: 9px;
98
+ background: light-dark(rgba(255,255,255,.9), rgba(255,255,255,.055));
99
+ border: 1px solid light-dark(rgba(0,0,0,.07), rgba(255,255,255,.08));
100
+ box-shadow: 0 1px 2px rgba(0,0,0,.05);
101
+ }
102
+ .lc-chip:hover { border-color: light-dark(rgba(0,0,0,.14), rgba(255,255,255,.16)); }
103
+
104
+ /* segmented tabs */
105
+ .lc-tabs { padding: 8px 12px 0; gap: 4px; border-bottom: 1px solid light-dark(rgba(0,0,0,.07), rgba(255,255,255,.08)); }
106
+ .lc-tab { border-radius: 999px; padding: 5px 13px; font-size: 12.5px; font-weight: 600; opacity: .68; transition: background .14s, opacity .14s, color .14s; }
107
+ .lc-tab[data-on] {
108
+ opacity: 1; color: light-dark(#fff, #fff);
109
+ background: linear-gradient(135deg, #4285f4, #5b8def);
110
+ box-shadow: 0 2px 8px rgba(66,133,244,.35);
111
+ }
112
+ .lc-tab[data-on]:hover { background: linear-gradient(135deg, #3b78e0, #4f83e4); }
113
+
114
+ /* toolbar grouping */
115
+ .lc-toolgroup { display: inline-flex; align-items: center; gap: 4px; }
116
+ .lc-toolsep { width: 1px; height: 18px; margin: 0 3px; background: light-dark(rgba(0,0,0,.12), rgba(255,255,255,.15)); }
117
+ .lc-icobtn { transition: background .12s, border-color .12s, transform .08s; }
118
+ .lc-icobtn:active { transform: scale(.93); }
119
+ .lc-btn:active, .lc-key:active { transform: scale(.96); }
120
+
121
+ /* live screen: device-like framing, badge, tap ripple */
122
+ .lc-screen-img {
123
+ background: radial-gradient(120% 90% at 50% 0%, light-dark(#eef1f6, #1b2027) 0%, light-dark(#e4e8ef, #0d1014) 100%);
124
+ padding: 10px;
125
+ }
126
+ .lc-screen-img canvas {
127
+ border-radius: 12px;
128
+ box-shadow: 0 10px 30px rgba(0,0,0,.42), 0 0 0 1px rgba(255,255,255,.06);
129
+ background: #000;
130
+ }
131
+ .lc-screen-badge {
132
+ position: absolute; top: 14px; left: 14px; z-index: 3;
133
+ font-size: 10px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
134
+ background: rgba(8,10,14,.62); color: #dfe6f2; backdrop-filter: blur(6px);
135
+ border: 1px solid rgba(255,255,255,.12); pointer-events: none;
136
+ }
137
+ .lc-screen-badge.warn { color: #ffd479; border-color: rgba(251,192,45,.4); }
138
+ .lc-tap-ripple {
139
+ position: fixed; z-index: 4; width: 30px; height: 30px; margin: -15px 0 0 -15px;
140
+ border-radius: 50%; background: rgba(66,133,244,.5); pointer-events: none;
141
+ animation: lc-ripple .42s ease-out forwards;
142
+ }
143
+ @keyframes lc-ripple { from { transform: scale(.35); opacity: .95; } to { transform: scale(1.7); opacity: 0; } }
144
+
145
+ /* friendlier empty states */
146
+ .lc-empty { flex-direction: column; gap: 10px; text-align: center; padding: 0 24px; }
147
+ .lc-empty-icon { font-size: 30px; opacity: .45; }
148
+
149
+ /* log line polish */
150
+ .lc-line { transition: background .1s; }
151
+ .lc-line .tag { cursor: pointer; }
152
+ .lc-line .tag:hover { text-decoration: underline; }
153
+ .lc-hit { background: linear-gradient(180deg, rgba(66,133,244,.42), rgba(66,133,244,.28)); border-radius: 3px; padding: 0 1px; }
154
+
155
+ /* RE workbench headers */
156
+ .lc-re-head { letter-spacing: .2px; background: light-dark(rgba(0,0,0,.025), rgba(255,255,255,.03)); }
76
157
  .lc-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; flex-wrap: wrap; }
77
158
  .lc-select, .lc-search { background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)); border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 7px; color: inherit; font: inherit; font-size: 12px; padding: 5px 9px; }
78
159
  .lc-select:focus, .lc-search:focus { outline: 2px solid rgba(66,133,244,.35); border-color: rgba(66,133,244,.5); }
@@ -190,6 +271,8 @@ window.__ModuleLoader__.load({
190
271
  const API_BASE = "/api/dsh-logcat";
191
272
  const LEVELS = ["V", "D", "I", "W", "E", "F"];
192
273
  const LEVEL_TITLES = { V: "详细", D: "调试", I: "信息", W: "警告", E: "错误", F: "致命" };
274
+ /** H.264 screen-stream tiers: encoded size → bitrate (bps). */
275
+ const VIDEO_TIERS = { "360x800": 1200000, "540x1200": 2500000, "720x1600": 4000000, "1080x2400": 6000000 };
193
276
 
194
277
  /** The Logcat panel view. */
195
278
  function LogcatPanel({ controller }) {
@@ -253,6 +336,16 @@ window.__ModuleLoader__.load({
253
336
  const [screenFps, setScreenFps] = useState(0);
254
337
  const [screenHasFrame, setScreenHasFrame] = useState(false);
255
338
  const [hostScreenCapable, setHostScreenCapable] = useState(false);
339
+ // null = unknown (status not fetched yet); false = the running GUI host predates the
340
+ // H.264 engine, so the video stream cannot work until that process is restarted.
341
+ const [hostVideoCapable, setHostVideoCapable] = useState(null);
342
+ // serial -> { width, height, rotation }: the device's current oriented display
343
+ // geometry, used to translate touch positions out of frame space.
344
+ const [displays, setDisplays] = useState({});
345
+ const [screenMode, setScreenMode] = useState(() => { try { return localStorage.getItem("dsh-logcat-screenmode-v2") === "png" ? "png" : "video"; } catch { return "video"; } });
346
+ const [videoTier, setVideoTier] = useState(() => { try { const t = localStorage.getItem("dsh-logcat-videotier"); return t !== null && VIDEO_TIERS[t] !== undefined ? t : "540x1200"; } catch { return "540x1200"; } });
347
+ const [videoInfo, setVideoInfo] = useState(null);
348
+ const [ripples, setRipples] = useState([]);
256
349
  const panelOpen = useSyncExternalStore(controller.subscribe, controller.getSnapshot).panelOpen;
257
350
 
258
351
  const wsRef = useRef(null);
@@ -276,6 +369,12 @@ window.__ModuleLoader__.load({
276
369
  const screenDecodingRef = useRef(false);
277
370
  const screenPendingRef = useRef(null);
278
371
  const screenFpsTsRef = useRef([]);
372
+ const decoderRef = useRef(null);
373
+ const screenModeRef = useRef(screenMode);
374
+ const videoTierRef = useRef(videoTier);
375
+ const videoInfoRef = useRef(null);
376
+ const videoFallbackTimerRef = useRef(null);
377
+ videoInfoRef.current = videoInfo;
279
378
 
280
379
  useEffect(() => { serialRef.current = serial; }, [serial]);
281
380
  useEffect(() => { pausedRef.current = paused; }, [paused]);
@@ -339,6 +438,22 @@ window.__ModuleLoader__.load({
339
438
  }
340
439
  };
341
440
 
441
+ /**
442
+ * Re-assert the live-screen intent. `ready` also arrives after a reconnect, and the host
443
+ * may have been restarted in the meantime — without this the panel would sit on a dead
444
+ * stream until the user happens to toggle something.
445
+ */
446
+ const reassertStream = () => {
447
+ const ws = wsRef.current;
448
+ const target = serialRef.current;
449
+ if (ws === null || ws.readyState !== WebSocket.OPEN || target === "" || !screenWantRef.current) return;
450
+ const useVideo = screenModeRef.current === "video" && typeof VideoDecoder !== "undefined";
451
+ const tier = videoTierRef.current;
452
+ ws.send(JSON.stringify({ type: "video", serial: target, on: useVideo, size: tier, bitRate: VIDEO_TIERS[tier] ?? 2500000 }));
453
+ ws.send(JSON.stringify({ type: "screen", serial: target, on: !useVideo }));
454
+ setScreenOn(true);
455
+ };
456
+
342
457
  const applyFrame = (frame) => {
343
458
  if (frame.type === "ready") {
344
459
  setAdbPath(frame.adbPath ?? "");
@@ -351,6 +466,7 @@ window.__ModuleLoader__.load({
351
466
  const first = (frame.devices ?? []).find((d) => d.state === "device");
352
467
  if (first !== undefined) pickSerial(first.serial);
353
468
  }
469
+ reassertStream();
354
470
  } else if (frame.type === "devices") {
355
471
  setDevices(frame.devices ?? []);
356
472
  devicesRef.current = frame.devices ?? [];
@@ -390,6 +506,19 @@ window.__ModuleLoader__.load({
390
506
  }
391
507
  } else if (frame.type === "crash") {
392
508
  setLastCrash(frame.crash ?? null);
509
+ } else if (frame.type === "svideo-init") {
510
+ if (frame.serial === serialRef.current) setupVideoDecoder(frame);
511
+ } else if (frame.type === "video-state") {
512
+ if (frame.serial !== serialRef.current) return;
513
+ if (frame.display !== undefined && frame.display !== null) setDisplays((prev) => ({ ...prev, [frame.serial]: frame.display }));
514
+ setVideoInfo((prev) => ({
515
+ ...(prev ?? {}),
516
+ running: frame.running === true,
517
+ idle: frame.idle === true,
518
+ size: frame.size ?? prev?.size,
519
+ bitRate: frame.bitRate ?? prev?.bitRate,
520
+ error: frame.running === true ? (frame.error ?? prev?.error) : (frame.error ?? prev?.error ?? "视频流已停止"),
521
+ }));
393
522
  }
394
523
  };
395
524
  applyFrameRef.current = applyFrame;
@@ -447,6 +576,8 @@ window.__ModuleLoader__.load({
447
576
  setStreaming(body.streaming ?? []);
448
577
  setEventsStreaming(body.eventsStreaming ?? []);
449
578
  setHostScreenCapable(body.screenCapable === true);
579
+ setHostVideoCapable(body.videoCapable === true || Array.isArray(body.videoStreaming));
580
+ setDisplays(body.displays ?? {});
450
581
  setCurrentPackage(body.currentPackage ?? "");
451
582
  setCurrentVersion(body.currentVersion ?? "");
452
583
  setLatestVersion(body.latestVersion ?? "");
@@ -825,20 +956,109 @@ window.__ModuleLoader__.load({
825
956
  reDumpAt("0x" + addr.toLowerCase(), reDumpLen);
826
957
  };
827
958
 
959
+ // Physical keyboard → device (while the Screen tab is focused and no text field is active).
960
+ useEffect(() => {
961
+ if (tab !== "screen" || !panelOpen || serial === "") return undefined;
962
+ const KEYS = { Enter: 66, Backspace: 67, Escape: 4, Tab: 61, Delete: 112, ArrowUp: 19, ArrowDown: 20, ArrowLeft: 21, ArrowRight: 22, Home: 3 };
963
+ const onKeyDown = (ev) => {
964
+ const el = document.activeElement;
965
+ if (el !== null && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable === true)) return;
966
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
967
+ if (KEYS[ev.key] !== undefined) {
968
+ ev.preventDefault();
969
+ fetch(API_BASE + "/keyevent", {
970
+ method: "POST",
971
+ headers: { "content-type": "application/json" },
972
+ body: JSON.stringify({ serial, code: KEYS[ev.key] }),
973
+ }).catch(() => { /* offline */ });
974
+ return;
975
+ }
976
+ if (ev.key.length === 1) {
977
+ ev.preventDefault();
978
+ // Single printable char: ASCII only (use the text box for IME/Chinese input).
979
+ const ch = ev.key === " " ? "%s" : ev.key.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
980
+ fetch(API_BASE + "/exec", {
981
+ method: "POST",
982
+ headers: { "content-type": "application/json" },
983
+ body: JSON.stringify({ serial, command: `input text "${ch}"` }),
984
+ }).catch(() => { /* offline */ });
985
+ }
986
+ };
987
+ window.addEventListener("keydown", onKeyDown);
988
+ return () => window.removeEventListener("keydown", onKeyDown);
989
+ }, [tab, panelOpen, serial]);
990
+
991
+ // Esc closes the panel — except on the Screen tab, where Esc is the device BACK key.
992
+ useEffect(() => {
993
+ if (!panelOpen || tab === "screen") return undefined;
994
+ const onKey = (ev) => {
995
+ if (ev.key !== "Escape") return;
996
+ const el = document.activeElement;
997
+ if (el !== null && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) {
998
+ if (typeof el.blur === "function") el.blur();
999
+ return;
1000
+ }
1001
+ controller.close();
1002
+ };
1003
+ window.addEventListener("keydown", onKey);
1004
+ return () => window.removeEventListener("keydown", onKey);
1005
+ }, [panelOpen, tab]);
1006
+
828
1007
  // Live screen stream: on while the 屏幕 tab is open, the panel is open, and a device is selected.
1008
+ // Preferred path is the H.264 stream (30fps+); the PNG screencap path is the fallback.
829
1009
  useEffect(() => {
830
1010
  const prev = prevSerialRef.current;
831
1011
  prevSerialRef.current = serial;
1012
+ screenModeRef.current = screenMode;
1013
+ videoTierRef.current = videoTier;
832
1014
  const want = tab === "screen" && serial !== "" && panelOpen;
833
1015
  screenWantRef.current = want;
834
1016
  const ws = wsRef.current;
1017
+ const useVideo = want && screenMode === "video" && typeof VideoDecoder !== "undefined" && hostVideoCapable !== false;
835
1018
  if (ws !== null && ws.readyState === WebSocket.OPEN) {
836
- if (prev !== "" && prev !== serial) ws.send(JSON.stringify({ type: "screen", serial: prev, on: false }));
837
- ws.send(JSON.stringify({ type: "screen", serial, on: want }));
1019
+ if (prev !== "" && prev !== serial) {
1020
+ ws.send(JSON.stringify({ type: "screen", serial: prev, on: false }));
1021
+ ws.send(JSON.stringify({ type: "video", serial: prev, on: false }));
1022
+ }
1023
+ ws.send(JSON.stringify({ type: "video", serial, on: useVideo, size: videoTier, bitRate: VIDEO_TIERS[videoTier] ?? 2500000 }));
1024
+ ws.send(JSON.stringify({ type: "screen", serial, on: want && !useVideo }));
838
1025
  }
839
1026
  setScreenOn(want);
840
- if (!want) setScreenImg(null);
841
- }, [tab, serial, panelOpen]);
1027
+ if (!want) {
1028
+ setScreenHasFrame(false);
1029
+ setVideoInfo(null);
1030
+ closeVideoDecoder();
1031
+ }
1032
+ if (videoFallbackTimerRef.current !== null) { clearTimeout(videoFallbackTimerRef.current); videoFallbackTimerRef.current = null; }
1033
+ if (useVideo) {
1034
+ // If no avcC init arrives in 6s, the device/browser cannot do H.264 → PNG mode.
1035
+ videoFallbackTimerRef.current = setTimeout(() => {
1036
+ videoFallbackTimerRef.current = null;
1037
+ if (videoInfoRef.current?.codec) return;
1038
+ // A stale GUI host simply ignores the `video` request. Do NOT fall back to
1039
+ // PNG mode here and do NOT persist anything: this is a host-version problem,
1040
+ // and it must heal by itself once the host is restarted.
1041
+ if (hostVideoCapable === false) {
1042
+ setVideoInfo({ error: "宿主进程仍是旧版(v" + currentVersion + "),H.264 视频流不可用 —— 重启 GUI 后刷新即恢复" });
1043
+ return;
1044
+ }
1045
+ setVideoInfo({ error: "视频流启动超时,已回退到截图模式" });
1046
+ setScreenMode("png");
1047
+ // Deliberately not persisted: an automatic fallback must never become sticky,
1048
+ // otherwise a transient failure would disable the video path forever.
1049
+ }, 6000);
1050
+ }
1051
+ // Keep the oriented display geometry fresh while the screen tab is open: touch
1052
+ // mapping depends on it and the device can rotate at any moment.
1053
+ if (!want) return;
1054
+ const displayTimer = setInterval(() => {
1055
+ fetch(API_BASE + "/status")
1056
+ .then((res) => res.json())
1057
+ .then((body) => { if (body.displays !== undefined) setDisplays(body.displays); })
1058
+ .catch(() => { /* offline */ });
1059
+ }, 4000);
1060
+ return () => clearInterval(displayTimer);
1061
+ }, [tab, serial, panelOpen, screenMode, videoTier, hostVideoCapable]);
842
1062
 
843
1063
  // Binary WS frames: 4-byte JSON header length + header + PNG payload.
844
1064
  // Decoding goes through createImageBitmap into a canvas (no base64
@@ -860,6 +1080,11 @@ window.__ModuleLoader__.load({
860
1080
  c2d.drawImage(bmp, 0, 0);
861
1081
  bmp.close();
862
1082
  setScreenHasFrame(true);
1083
+ const now = performance.now();
1084
+ const ts = screenFpsTsRef.current;
1085
+ ts.push(now);
1086
+ while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
1087
+ setScreenFps(Math.min(Math.round(ts.length / 1.5), 120));
863
1088
  } catch { /* undecodable frame — skip */ }
864
1089
  }
865
1090
  } finally {
@@ -872,14 +1097,14 @@ window.__ModuleLoader__.load({
872
1097
  const view = new DataView(buffer);
873
1098
  const hlen = view.getUint32(0);
874
1099
  const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, hlen)));
875
- if (header.type !== "simage" || header.serial !== serialRef.current) return;
1100
+ if (header.serial !== serialRef.current) return;
876
1101
  const bytes = new Uint8Array(buffer, 4 + hlen);
877
- // Sliding 1.5s window → rough fps readout.
878
- const now = performance.now();
879
- const ts = screenFpsTsRef.current;
880
- ts.push(now);
881
- while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
882
- setScreenFps(Math.min(ts.length, 10));
1102
+ if (header.type === "svideo") {
1103
+ decodeVideoFrame(header, bytes);
1104
+ return;
1105
+ }
1106
+ if (header.type !== "simage") return;
1107
+ // PNG fallback path: keep only the newest pending frame.
883
1108
  screenPendingRef.current = bytes;
884
1109
  void pumpScreenFrame();
885
1110
  } catch { /* malformed frame */ }
@@ -887,19 +1112,55 @@ window.__ModuleLoader__.load({
887
1112
  handleBinaryRef.current = handleBinary;
888
1113
 
889
1114
  const screenToDevice = (e) => {
890
- const img = screenImgRef.current;
891
- if (img === null || img.naturalWidth === 0) return null;
892
- const imgRect = img.getBoundingClientRect();
893
- const x = Math.round(((e.clientX - imgRect.left) / imgRect.width) * img.naturalWidth);
894
- const y = Math.round(((e.clientY - imgRect.top) / imgRect.height) * img.naturalHeight);
895
- if (x < 0 || y < 0 || x >= img.naturalWidth || y >= img.naturalHeight) return null;
896
- return { x, y };
1115
+ const canvas = screenCanvasRef.current;
1116
+ if (canvas === null || canvas.width <= 1) return null;
1117
+ const rect = canvas.getBoundingClientRect();
1118
+ if (rect.width <= 0 || rect.height <= 0) return null;
1119
+ const u = (e.clientX - rect.left) / rect.width;
1120
+ const v = (e.clientY - rect.top) / rect.height;
1121
+ if (u < 0 || u > 1 || v < 0 || v > 1) return null;
1122
+ // Touch coordinates live in the device's CURRENT oriented display space, which is
1123
+ // not the encoded frame space (a landscape device encodes a portrait frame unless
1124
+ // the stream size follows the rotation).
1125
+ const disp = displays[serial];
1126
+ const w = disp !== undefined && disp.width > 0 ? disp.width : canvas.width;
1127
+ const h = disp !== undefined && disp.height > 0 ? disp.height : canvas.height;
1128
+ return { x: Math.round(u * w), y: Math.round(v * h) };
897
1129
  };
898
1130
 
899
1131
  const screenMouseDown = (e) => {
1132
+ if (e.button === 2) return; // right-click handled by contextmenu (back key)
900
1133
  const pt = screenToDevice(e);
901
1134
  if (pt === null) return;
902
- screenDragRef.current = { ...pt, clientX: e.clientX, clientY: e.clientY };
1135
+ const now = Date.now();
1136
+ const last = screenDragRef.current;
1137
+ // double-click → two taps
1138
+ if (last !== null && last.tapOnly === true && now - last.endedAt < 320 && Math.abs(last.x - pt.x) < 48 && Math.abs(last.y - pt.y) < 48) {
1139
+ screenDragRef.current = null;
1140
+ tapAt(pt.x, pt.y);
1141
+ tapAt(pt.x, pt.y);
1142
+ return;
1143
+ }
1144
+ showRipple(e);
1145
+ screenDragRef.current = { ...pt, clientX: e.clientX, clientY: e.clientY, startedAt: now, held: false };
1146
+ };
1147
+
1148
+ /** Send a tap at device coordinates. */
1149
+ const tapAt = (x, y) => {
1150
+ fetch(API_BASE + "/exec", {
1151
+ method: "POST",
1152
+ headers: { "content-type": "application/json" },
1153
+ body: JSON.stringify({ serial, command: `input tap ${x} ${y}` }),
1154
+ }).catch(() => { /* offline */ });
1155
+ };
1156
+
1157
+ /** Send a swipe (used by drag, wheel and long-press). */
1158
+ const swipeAt = (x1, y1, x2, y2, ms) => {
1159
+ fetch(API_BASE + "/exec", {
1160
+ method: "POST",
1161
+ headers: { "content-type": "application/json" },
1162
+ body: JSON.stringify({ serial, command: `input swipe ${x1} ${y1} ${x2} ${y2} ${ms}` }),
1163
+ }).catch(() => { /* offline */ });
903
1164
  };
904
1165
 
905
1166
  const screenMouseUp = (e) => {
@@ -911,13 +1172,43 @@ window.__ModuleLoader__.load({
911
1172
  const dx = pt.x - drag.x;
912
1173
  const dy = pt.y - drag.y;
913
1174
  const dist = Math.sqrt(dx * dx + dy * dy);
914
- const command = dist < 12
915
- ? `input tap ${drag.x} ${drag.y}`
916
- : `input swipe ${drag.x} ${drag.y} ${pt.x} ${pt.y} 120`;
917
- fetch(API_BASE + "/exec", {
1175
+ const heldMs = Date.now() - (drag.startedAt ?? Date.now());
1176
+ if (dist < 24 && heldMs > 620) {
1177
+ // long press: swipe in place
1178
+ swipeAt(drag.x, drag.y, drag.x, drag.y, 800);
1179
+ screenDragRef.current = { x: pt.x, y: pt.y, tapOnly: true, endedAt: Date.now() };
1180
+ return;
1181
+ }
1182
+ if (dist < 24) {
1183
+ tapAt(drag.x, drag.y);
1184
+ screenDragRef.current = { x: pt.x, y: pt.y, tapOnly: true, endedAt: Date.now() };
1185
+ return;
1186
+ }
1187
+ swipeAt(drag.x, drag.y, pt.x, pt.y, 120);
1188
+ };
1189
+
1190
+ /** Mouse wheel over the live screen → vertical swipe (natural scrolling). */
1191
+ const lastWheelRef = { current: 0 };
1192
+ const screenWheel = (e) => {
1193
+ const pt = screenToDevice(e);
1194
+ if (pt === null) return;
1195
+ e.preventDefault();
1196
+ const now = Date.now();
1197
+ if (now - lastWheelRef.current < 220) return;
1198
+ lastWheelRef.current = now;
1199
+ const step = Math.round((e.deltaY > 0 ? 1 : -1) * Math.min(Math.abs(e.deltaY), 420));
1200
+ const disp = displays[serial];
1201
+ const dh = disp !== undefined && disp.height > 0 ? disp.height : 2408;
1202
+ swipeAt(pt.x, pt.y, pt.x, Math.max(4, Math.min(dh - 12, pt.y - step)), 140);
1203
+ };
1204
+
1205
+ /** Right click → Android BACK key. */
1206
+ const screenContextMenu = (e) => {
1207
+ e.preventDefault();
1208
+ fetch(API_BASE + "/keyevent", {
918
1209
  method: "POST",
919
1210
  headers: { "content-type": "application/json" },
920
- body: JSON.stringify({ serial, command }),
1211
+ body: JSON.stringify({ serial, key: "back" }),
921
1212
  }).catch(() => { /* offline */ });
922
1213
  };
923
1214
 
@@ -933,6 +1224,18 @@ window.__ModuleLoader__.load({
933
1224
  }).catch(() => { /* offline */ });
934
1225
  };
935
1226
 
1227
+ /** Show a short ripple where the user tapped the live screen. */
1228
+ const showRipple = (e) => {
1229
+ const wrap = screenWrapRef.current;
1230
+ if (wrap === null) return;
1231
+ const id = Date.now() + Math.random();
1232
+ // Viewport-fixed: the ripple must sit under the cursor no matter how the canvas is
1233
+ // scaled, letterboxed or rotated inside the wrapper.
1234
+ const ripple = { id, x: e.clientX, y: e.clientY };
1235
+ setRipples((prev) => [...prev.slice(-3), ripple]);
1236
+ setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 430);
1237
+ };
1238
+
936
1239
  const downloadScreen = () => {
937
1240
  const canvas = screenCanvasRef.current;
938
1241
  if (canvas === null || canvas.width <= 1) return;
@@ -953,27 +1256,92 @@ window.__ModuleLoader__.load({
953
1256
  try { el.requestFullscreen?.(); } catch { /* denied */ }
954
1257
  };
955
1258
 
1259
+ // ---- H.264 path (WebCodecs): screenrecord stream → VideoDecoder → canvas ----
1260
+
1261
+ const closeVideoDecoder = () => {
1262
+ const decoder = decoderRef.current;
1263
+ decoderRef.current = null;
1264
+ if (decoder !== null) { try { decoder.close(); } catch { /* already closed */ } }
1265
+ };
1266
+
1267
+ /** Paint one decoded VideoFrame and update the fps readout. */
1268
+ const drawVideoFrame = (frame) => {
1269
+ try {
1270
+ const canvas = screenCanvasRef.current;
1271
+ if (canvas !== null) {
1272
+ if (canvas.width !== frame.displayWidth) canvas.width = frame.displayWidth;
1273
+ if (canvas.height !== frame.displayHeight) canvas.height = frame.displayHeight;
1274
+ const c2d = canvas.getContext("2d");
1275
+ c2d.drawImage(frame, 0, 0, canvas.width, canvas.height);
1276
+ setScreenHasFrame(true);
1277
+ const now = performance.now();
1278
+ const ts = screenFpsTsRef.current;
1279
+ ts.push(now);
1280
+ while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
1281
+ setScreenFps(Math.min(Math.round(ts.length / 1.5), 120));
1282
+ }
1283
+ } finally {
1284
+ try { frame.close(); } catch { /* already closed */ }
1285
+ }
1286
+ };
1287
+
1288
+ /** Configure a fresh VideoDecoder from the host's avcC description. */
1289
+ const setupVideoDecoder = (init) => {
1290
+ closeVideoDecoder();
1291
+ if (typeof VideoDecoder === "undefined") {
1292
+ setVideoInfo((prev) => ({ ...(prev ?? {}), error: "此浏览器不支持 WebCodecs,已回退截图模式" }));
1293
+ return;
1294
+ }
1295
+ try {
1296
+ const raw = atob(init.avcC);
1297
+ const description = new Uint8Array(raw.length);
1298
+ for (let i = 0; i < raw.length; i++) description[i] = raw.charCodeAt(i);
1299
+ const decoder = new VideoDecoder({
1300
+ output: drawVideoFrame,
1301
+ error: (err) => setVideoInfo((prev) => ({ ...(prev ?? {}), error: String(err?.message ?? err) })),
1302
+ });
1303
+ decoder.configure({ codec: init.codec, description, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" });
1304
+ decoderRef.current = decoder;
1305
+ setVideoInfo((prev) => ({ ...(prev ?? {}), codec: init.codec, size: init.size ?? prev?.size, running: true, error: undefined }));
1306
+ } catch (error) {
1307
+ setVideoInfo((prev) => ({ ...(prev ?? {}), error: String(error?.message ?? error) }));
1308
+ closeVideoDecoder();
1309
+ }
1310
+ };
1311
+
1312
+ /** Feed one AVCC access unit from the host into the decoder. */
1313
+ const decodeVideoFrame = (header, bytes) => {
1314
+ const decoder = decoderRef.current;
1315
+ if (decoder === null || decoder.state !== "configured") return;
1316
+ try {
1317
+ decoder.decode(new EncodedVideoChunk({
1318
+ type: header.key === true ? "key" : "delta",
1319
+ timestamp: (Number(header.ts) || Date.now()) * 1000,
1320
+ data: bytes,
1321
+ }));
1322
+ } catch { /* dropped frame */ }
1323
+ };
1324
+
956
1325
  return h("div", { className: "lc-panel" },
957
1326
  h("div", { className: "lc-header" },
958
- h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
959
- h("span", { "aria-hidden": true }, "‹"),
960
- h("span", null, "关闭"),
961
- ),
962
1327
  h("h2", { className: "lc-title" }, "Logcat"),
1328
+ currentVersion !== "" ? h("span", { className: "lc-version" }, "v" + currentVersion) : null,
963
1329
  h("span", { className: "lc-dot " + (live ? "on" : connected ? "warn" : "off") }),
964
- h("span", { style: { fontSize: 12, color: "var(--lc-dim, #9e9e9e)" } },
965
- live ? "实机日志流中" : devices.length === 0 ? "无设备" : connected ? "未选设备" : "未连接"),
1330
+ h("span", { style: { fontSize: 12, color: "var(--lc-dim, #9e9e9e)", flex: "none" } },
1331
+ live ? "流中" : devices.length === 0 ? "无设备" : connected ? "未选设备" : "未连接"),
966
1332
  h("select", {
967
1333
  className: "lc-select",
968
1334
  value: serial,
969
1335
  onChange: (e) => pickSerial(e.target.value),
970
1336
  title: "选择设备",
1337
+ style: { flex: 1, minWidth: 0 },
971
1338
  },
972
1339
  devices.length === 0
973
1340
  ? h("option", { value: "" }, "无设备 — 请连接并开启 USB 调试")
974
1341
  : devices.map((d) =>
975
1342
  h("option", { key: d.serial, value: d.serial },
976
1343
  (d.model !== "" ? d.model + " · " : "") + d.serial + " [" + d.state + "]"))),
1344
+ h("button", { type: "button", className: "lc-close", onClick: () => controller.close(), title: "关闭面板" }, "✕"),
977
1345
  ),
978
1346
  h("div", { className: "lc-stats" },
979
1347
  stats?.model
@@ -1155,18 +1523,61 @@ window.__ModuleLoader__.load({
1155
1523
  : tab === "screen"
1156
1524
  ? h("div", { className: "lc-screen" },
1157
1525
  h("div", { className: "lc-screen-bar" },
1158
- h("span", { className: "lc-dot " + (screenOn && hostScreenCapable ? "on" : "off") }),
1159
- h("span", null, !hostScreenCapable ? "宿主版本过旧" : (screenOn ? "投屏中 (~" + screenFps + "fps)" : "投屏未开启")),
1160
- h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 140 } }),
1526
+ h("span", { className: "lc-dot " + (screenOn ? (videoInfo?.error === undefined && videoInfo?.idle !== true ? "on" : "warn") : "off") }),
1527
+ h("span", { title: videoInfo?.error ?? undefined },
1528
+ hostVideoCapable === false ? "宿主版本过旧"
1529
+ : screenOn
1530
+ ? (screenMode === "video"
1531
+ ? "流畅 " + screenFps + "fps · " + (videoInfo?.size ?? videoTier)
1532
+ : "高清 " + screenFps + "fps · " + (videoInfo?.size ?? "PNG"))
1533
+ : "投屏未开启"),
1534
+ h("select", {
1535
+ className: "lc-select",
1536
+ value: screenMode,
1537
+ onChange: (e) => { const m = e.target.value; setScreenMode(m); try { localStorage.setItem("dsh-logcat-screenmode-v2", m); } catch { /* private */ } },
1538
+ title: "流畅=H.264 视频流(30fps+);高清=PNG 截图(静帧清晰)",
1539
+ style: { padding: "2px 6px" },
1540
+ },
1541
+ h("option", { value: "video" }, "流畅"),
1542
+ h("option", { value: "png" }, "高清")),
1543
+ h("select", {
1544
+ className: "lc-select",
1545
+ value: videoTier,
1546
+ onChange: (e) => { const t = e.target.value; setVideoTier(t); try { localStorage.setItem("dsh-logcat-videotier", t); } catch { /* private */ } },
1547
+ title: "投屏分辨率档位(越低越省带宽/CPU)",
1548
+ style: { padding: "2px 6px" },
1549
+ },
1550
+ Object.keys(VIDEO_TIERS).map((t) => h("option", { key: t, value: t }, t))),
1551
+ h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 120 } }),
1552
+ h("button", { type: "button", className: "lc-btn", onClick: () => sendKey("wakeup"), title: "唤醒设备屏幕(等效按电源键点亮)" }, "唤醒"),
1161
1553
  h("button", { type: "button", className: "lc-btn", onClick: downloadScreen, title: "下载当前帧 PNG" }, "下载"),
1162
1554
  h("button", { type: "button", className: "lc-btn", onClick: toggleScreenFullscreen, title: "全屏查看(Esc 退出)" }, "⛶"),
1163
- h("span", { className: "lc-screen-hint" }, "点击=点按 · 拖动=滑动"),
1164
1555
  ),
1165
- !hostScreenCapable
1166
- ? h("div", { className: "lc-empty" }, "⚠ 运行中的 GUI 宿主版本过旧(0.6.0-preview.1)——请重启 GUI(dsh web)后刷新,投屏功能才会生效")
1167
- : h("div", { className: "lc-screen-img", ref: screenWrapRef, onMouseDown: screenMouseDown, onMouseUp: screenMouseUp, onMouseLeave: screenMouseUp },
1168
- h("canvas", { ref: screenCanvasRef, alt: "设备屏幕", style: { width: "1080px", height: "2408px" } }),
1169
- !screenHasFrame ? h("div", { className: "lc-empty" }, "等待画面…(设备连接后自动开始)") : null,
1556
+ hostVideoCapable === false
1557
+ ? h("div", { className: "lc-empty" },
1558
+ h("span", { className: "lc-empty-icon" }, "⚠"),
1559
+ h("span", null, "运行中的 GUI 宿主版本过旧 —— 重启 GUI(dsh web)后刷新,投屏才会生效"))
1560
+ : h("div", {
1561
+ className: "lc-screen-img",
1562
+ ref: screenWrapRef,
1563
+ onMouseDown: screenMouseDown,
1564
+ onMouseUp: screenMouseUp,
1565
+ onMouseLeave: screenMouseUp,
1566
+ onWheel: screenWheel,
1567
+ onContextMenu: screenContextMenu,
1568
+ },
1569
+ h("canvas", { ref: screenCanvasRef, width: 540, height: 1200 }),
1570
+ screenOn
1571
+ ? h("div", { className: "lc-screen-badge" + (videoInfo?.error !== undefined && videoInfo?.error !== null ? " warn" : videoInfo?.idle === true ? " warn" : "") },
1572
+ (videoInfo?.idle === true ? "设备休眠 · 点「唤醒」" : (screenMode === "video" ? "H.264" : "PNG") + " · " + screenFps + "fps · " + (videoInfo?.size ?? videoTier))
1573
+ + (videoInfo?.error ? " · " + videoInfo.error : ""))
1574
+ : null,
1575
+ ripples.map((r) => h("span", { key: r.id, className: "lc-tap-ripple", style: { left: r.x, top: r.y } })),
1576
+ !screenHasFrame
1577
+ ? h("div", { className: "lc-empty" },
1578
+ h("span", { className: "lc-empty-icon" }, "📱"),
1579
+ h("span", null, devices.length === 0 ? "连接设备并开启 USB 调试后自动开始投屏" : "正在建立投屏流…"))
1580
+ : null,
1170
1581
  ),
1171
1582
  )
1172
1583
  : eventsOn
@@ -1174,7 +1585,9 @@ window.__ModuleLoader__.load({
1174
1585
  ? h("div", { className: "lc-empty" }, "events 事件缓冲(已开启流,等待事件…)")
1175
1586
  : h(VirtualLog, { entries: filteredEvents, scrollTop, onScrollTop: setScrollTop, bodyRef, keyword, onTagClick: addKeyword, onLineDblClick: copyLine, rowDensity }))
1176
1587
  : (filtered.length === 0
1177
- ? h("div", { className: "lc-empty" }, paused ? "已暂停(" + entries.length + " 条已缓冲)" : (devices.length === 0 ? "连接设备并开启 USB 调试后自动开始…" : "暂无日志"))
1588
+ ? h("div", { className: "lc-empty" },
1589
+ h("span", { className: "lc-empty-icon" }, paused ? "⏸" : devices.length === 0 ? "🔌" : "📄"),
1590
+ h("span", null, paused ? "已暂停(" + entries.length + " 条已缓冲)" : (devices.length === 0 ? "连接设备并开启 USB 调试后自动开始…" : "暂无日志")))
1178
1591
  : h(VirtualLog, { entries: filtered, scrollTop, onScrollTop: setScrollTop, bodyRef, keyword, onTagClick: addKeyword, onLineDblClick: copyLine, rowDensity })),
1179
1592
  tab === "log" && !eventsOn && live && !paused
1180
1593
  ? h("button", { type: "button", className: "lc-fab", onClick: togglePause, title: "暂停:冻结当前视图(新日志进入缓冲)" }, "⏸ 暂停")