phoneview 1.0.0__py3-none-any.whl

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.
phoneview/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """PhoneView: browser-based viewing and control of USB-connected phones."""
2
+ __version__ = "1.0.0"
phoneview/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from phoneview.server import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,198 @@
1
+ """Minimal asyncio client for the local adb server (smart-socket protocol, port 5037).
2
+
3
+ Talking to the adb server directly avoids parsing CLI output that differs between
4
+ platform-tools versions. The `adb` binary is only used to start the server when
5
+ it is not already running.
6
+
7
+ Protocol: https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/main/SERVICES.TXT
8
+ and .../SYNC.TXT
9
+ """
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import struct
15
+ import time
16
+ from dataclasses import dataclass, field
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ DEFAULT_HOST = "127.0.0.1"
21
+ DEFAULT_PORT = 5037
22
+ SYNC_DATA_MAX = 64 * 1024
23
+
24
+
25
+ class AdbError(RuntimeError):
26
+ pass
27
+
28
+
29
+ class AdbUnavailableError(AdbError):
30
+ """The adb server is not running and could not be started."""
31
+
32
+
33
+ @dataclass
34
+ class AdbDevice:
35
+ serial: str
36
+ state: str # device, unauthorized, offline, no permissions, ...
37
+ info: dict = field(default_factory=dict)
38
+
39
+ @property
40
+ def usb(self):
41
+ # Judge by the serial: TCP serials are host:port, wireless-debugging (mDNS)
42
+ # serials contain "._adb", emulators start with "emulator-". The usb:<bus>
43
+ # field of devices-l is only a positive hint: Windows adb never prints it.
44
+ if "usb" in self.info:
45
+ return True
46
+ return (":" not in self.serial and not self.serial.startswith("emulator-")
47
+ and "._adb" not in self.serial)
48
+
49
+
50
+ class AdbClient:
51
+ def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, adb_path=None):
52
+ self.host = host
53
+ self.port = port
54
+ self.adb_path = adb_path or os.environ.get("ADB") or shutil.which("adb")
55
+ self._last_start_attempt = 0.0
56
+
57
+ # -- connection helpers ---------------------------------------------------
58
+
59
+ async def _open(self):
60
+ try:
61
+ return await asyncio.wait_for(asyncio.open_connection(self.host, self.port), 3)
62
+ except (ConnectionRefusedError, OSError, asyncio.TimeoutError):
63
+ await self._start_server()
64
+ try:
65
+ return await asyncio.wait_for(asyncio.open_connection(self.host, self.port), 3)
66
+ except (ConnectionRefusedError, OSError, asyncio.TimeoutError) as exc:
67
+ raise AdbUnavailableError("adb server is not running") from exc
68
+
69
+ async def _start_server(self):
70
+ now = time.monotonic()
71
+ if not self.adb_path or now - self._last_start_attempt < 10:
72
+ if not self.adb_path:
73
+ raise AdbUnavailableError(
74
+ "adb was not found. Install Android platform-tools (Linux: 'sudo apt install adb') "
75
+ "or set the ADB environment variable to its path.")
76
+ raise AdbUnavailableError("adb server is not running")
77
+ self._last_start_attempt = now
78
+ log.info("Starting adb server")
79
+ proc = await asyncio.create_subprocess_exec(
80
+ self.adb_path, "start-server",
81
+ stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
82
+ try:
83
+ await asyncio.wait_for(proc.wait(), 15)
84
+ except asyncio.TimeoutError:
85
+ proc.kill()
86
+ raise AdbUnavailableError("adb start-server timed out")
87
+
88
+ @staticmethod
89
+ async def _send(reader, writer, request):
90
+ data = request.encode("utf-8")
91
+ writer.write(b"%04x" % len(data) + data)
92
+ await writer.drain()
93
+ status = await reader.readexactly(4)
94
+ if status == b"OKAY":
95
+ return
96
+ if status == b"FAIL":
97
+ raise AdbError(await AdbClient._read_string(reader))
98
+ raise AdbError(f"Unexpected adb response {status!r}")
99
+
100
+ @staticmethod
101
+ async def _read_string(reader):
102
+ length = int(await reader.readexactly(4), 16)
103
+ return (await reader.readexactly(length)).decode("utf-8", "replace")
104
+
105
+ @staticmethod
106
+ def _close(writer):
107
+ try:
108
+ writer.close()
109
+ except Exception:
110
+ pass
111
+
112
+ # -- host services ----------------------------------------------------------
113
+
114
+ async def devices(self):
115
+ reader, writer = await self._open()
116
+ try:
117
+ await self._send(reader, writer, "host:devices-l")
118
+ text = await self._read_string(reader)
119
+ finally:
120
+ self._close(writer)
121
+ return parse_devices(text)
122
+
123
+ async def open_stream(self, serial, service, timeout=5):
124
+ """Connect to `service` on the device; returns (reader, writer)."""
125
+ reader, writer = await self._open()
126
+ try:
127
+ await asyncio.wait_for(self._send(reader, writer, f"host:transport:{serial}"), timeout)
128
+ await asyncio.wait_for(self._send(reader, writer, service), timeout)
129
+ except BaseException:
130
+ self._close(writer)
131
+ raise
132
+ return reader, writer
133
+
134
+ async def shell(self, serial, command, timeout=10, limit=1 << 20):
135
+ reader, writer = await self.open_stream(serial, f"shell:{command}")
136
+ try:
137
+ data = await asyncio.wait_for(reader.read(limit), timeout)
138
+ chunks = [data]
139
+ while data:
140
+ data = await asyncio.wait_for(reader.read(limit), timeout)
141
+ chunks.append(data)
142
+ return b"".join(chunks).decode("utf-8", "replace")
143
+ finally:
144
+ self._close(writer)
145
+
146
+ async def getprops(self, serial, names):
147
+ out = {}
148
+ for name in names:
149
+ out[name] = (await self.shell(serial, f"getprop {name}")).strip()
150
+ return out
151
+
152
+ async def push(self, serial, data, remote_path, mode=0o644, timeout=30):
153
+ """Push bytes to `remote_path` using the sync protocol."""
154
+ reader, writer = await self.open_stream(serial, "sync:")
155
+ try:
156
+ spec = f"{remote_path},{mode}".encode()
157
+ writer.write(b"SEND" + struct.pack("<I", len(spec)) + spec)
158
+ for offset in range(0, len(data), SYNC_DATA_MAX):
159
+ chunk = data[offset:offset + SYNC_DATA_MAX]
160
+ writer.write(b"DATA" + struct.pack("<I", len(chunk)) + chunk)
161
+ await writer.drain()
162
+ writer.write(b"DONE" + struct.pack("<I", int(time.time())))
163
+ await writer.drain()
164
+ reply = await asyncio.wait_for(reader.readexactly(8), timeout)
165
+ if reply[:4] == b"FAIL":
166
+ length, = struct.unpack("<I", reply[4:])
167
+ message = (await reader.readexactly(length)).decode("utf-8", "replace")
168
+ raise AdbError(f"push failed: {message}")
169
+ if reply[:4] != b"OKAY":
170
+ raise AdbError(f"Unexpected sync reply {reply[:4]!r}")
171
+ writer.write(b"QUIT" + struct.pack("<I", 0))
172
+ await writer.drain()
173
+ finally:
174
+ self._close(writer)
175
+
176
+
177
+ def parse_devices(text):
178
+ """Parse `host:devices-l` output into AdbDevice objects."""
179
+ devices = []
180
+ for line in text.splitlines():
181
+ line = line.strip()
182
+ if not line:
183
+ continue
184
+ # States can contain spaces ("no permissions (...)"); info fields are key:value.
185
+ serial, _, rest = line.partition(" ")
186
+ if not serial:
187
+ continue
188
+ rest = rest.strip()
189
+ info = {}
190
+ state_words = []
191
+ for word in rest.split():
192
+ key, sep, value = word.partition(":")
193
+ if sep and key in {"usb", "product", "model", "device", "transport_id"}:
194
+ info[key] = value
195
+ else:
196
+ state_words.append(word)
197
+ devices.append(AdbDevice(serial, " ".join(state_words) or "unknown", info))
198
+ return devices
@@ -0,0 +1,432 @@
1
+ """Android backend: scrcpy server over adb, H.264 relayed untouched to the browser.
2
+
3
+ The host never decodes or re-encodes video, so a Raspberry Pi or thin client only
4
+ copies packets. The scrcpy server runs only while a browser is watching (plus a
5
+ short grace period), so an idle phone is not encoding its screen.
6
+ """
7
+ import asyncio
8
+ import contextlib
9
+ import logging
10
+ import secrets
11
+ import time
12
+ from dataclasses import dataclass, replace
13
+
14
+ from phoneview.android import scrcpy
15
+ from phoneview.android.adb import AdbClient, AdbError, AdbUnavailableError
16
+ from phoneview.backends.base import Backend, capabilities, setting
17
+ from phoneview.events import COORD_MAX
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ MAX_SIZES = (0, 1920, 1600, 1280, 1024, 800, 640)
22
+ FPS_CHOICES = (15, 24, 30, 45, 60)
23
+ FALLBACK_SIZES = (1024, 800, 640)
24
+ VIEWER_GRACE_SECONDS = 10
25
+ CONTROL_QUEUE_LIMIT = 256
26
+
27
+
28
+ class StreamEnded(Exception):
29
+ pass
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AndroidSettings:
34
+ max_size: int = 1280 # long edge in pixels; 0 = native
35
+ max_fps: int = 30
36
+ bit_rate_mbps: float = 4.0
37
+ serial: str = None
38
+ server_path: str = None
39
+
40
+ def __post_init__(self):
41
+ if self.max_size not in MAX_SIZES:
42
+ raise ValueError(f"max_size must be one of {MAX_SIZES}")
43
+ if self.max_fps not in FPS_CHOICES:
44
+ raise ValueError(f"max_fps must be one of {FPS_CHOICES}")
45
+ if type(self.bit_rate_mbps) not in (int, float) or not 0.5 <= self.bit_rate_mbps <= 16:
46
+ raise ValueError("Bit rate must be 0.5..16 Mbps")
47
+
48
+
49
+ def device_problem(state):
50
+ """User-facing guidance for adb device states other than 'device'."""
51
+ if state == "unauthorized":
52
+ return "Unlock the phone and accept the 'Allow USB debugging?' prompt (tick 'Always allow')."
53
+ if state.startswith("no permissions"):
54
+ return ("The host has no permission to open the USB device. On Linux add a udev rule for "
55
+ "Android devices (e.g. install the 'android-sdk-platform-tools-common' package) "
56
+ "and replug the phone.")
57
+ if state == "offline":
58
+ return "The phone is offline to adb. Replug the USB cable or toggle USB debugging."
59
+ if state in {"authorizing", "connecting"}:
60
+ return "Waiting for the phone to finish USB debugging authorisation..."
61
+ return f"Android device is in state '{state}'."
62
+
63
+
64
+ class AndroidBackend(Backend):
65
+ platform = "android"
66
+
67
+ def __init__(self, settings=None, hub=None, adb=None):
68
+ super().__init__(hub)
69
+ self.settings = settings or AndroidSettings()
70
+ self.adb = adb or AdbClient()
71
+ self.serial = None
72
+ self.streaming = False
73
+ self.width = self.height = 0
74
+ self.control = None # asyncio.Queue of bytes while streaming
75
+ self.pointers = {} # browser pointer id -> (x, y) in video pixels
76
+ self._config = b""
77
+ self._last_key = 0.0
78
+ self._restart = asyncio.Event()
79
+ self._fallback = 0
80
+ self._last_viewer = 0.0
81
+ self._rate_window = (time.monotonic(), 0, 0)
82
+ self._rates = (0.0, 0.0)
83
+ self.hub.request_keyframe = self._request_keyframe
84
+
85
+ # -- capabilities / settings ---------------------------------------------------
86
+
87
+ def capabilities(self):
88
+ s = self.settings
89
+ return capabilities(
90
+ video="h264" if self.device else None, touch=True, multitouch=True, live_touch=True,
91
+ keys=set(scrcpy.KEYCODES) | {"siri"}, text=True, scroll=True, clipboard=True,
92
+ commands={"expand_notifications", "expand_settings", "collapse_panels", "rotate",
93
+ "screen_off", "screen_on"},
94
+ settings=[
95
+ setting("max_size", "Max size (long edge)", s.max_size,
96
+ options=[(v, "Native" if v == 0 else f"{v} px") for v in MAX_SIZES]),
97
+ setting("max_fps", "Max fps", s.max_fps, options=[(v, str(v)) for v in FPS_CHOICES]),
98
+ setting("bit_rate_mbps", "Bit rate (Mbps)", s.bit_rate_mbps, minimum=0.5, maximum=16, step=0.5),
99
+ ],
100
+ )
101
+
102
+ async def update_settings(self, values):
103
+ if not isinstance(values, dict) or not set(values) <= {"max_size", "max_fps", "bit_rate_mbps"}:
104
+ raise ValueError("Unknown Android settings")
105
+ new = replace(self.settings, **values)
106
+ if new != self.settings:
107
+ self.settings = new
108
+ self._fallback = 0
109
+ self._restart.set()
110
+ self.notify()
111
+
112
+ def status(self):
113
+ now = time.monotonic()
114
+ start, frames, size = self._rate_window
115
+ if now - start >= 2:
116
+ elapsed = now - start
117
+ self._rates = (round((self.hub.frames_published - frames) / elapsed, 1),
118
+ round((self.hub.bytes_published - size) * 8 / elapsed / 1e6, 2))
119
+ self._rate_window = (now, self.hub.frames_published, self.hub.bytes_published)
120
+ return {"streaming": self.streaming, "control": self.streaming, "width": self.width,
121
+ "height": self.height, "fps": self._rates[0], "mbps": self._rates[1]}
122
+
123
+ # -- lifecycle ---------------------------------------------------------------------
124
+
125
+ async def find_device(self):
126
+ """Return the adb device to use, or None; sets a helpful error otherwise."""
127
+ devices = await self.adb.devices()
128
+ if self.settings.serial:
129
+ devices = [d for d in devices if d.serial == self.settings.serial]
130
+ else:
131
+ devices = [d for d in devices if d.usb]
132
+ ready = [d for d in devices if d.state == "device"]
133
+ if ready:
134
+ return ready[0]
135
+ if devices:
136
+ self.error = device_problem(devices[0].state)
137
+ else:
138
+ self.error = "No Android device found. Connect the phone by USB and enable USB debugging."
139
+ return None
140
+
141
+ async def _describe(self, serial):
142
+ props = await self.adb.getprops(serial, [
143
+ "ro.product.manufacturer", "ro.product.model", "ro.build.version.release",
144
+ "ro.build.version.sdk"])
145
+ sdk = props["ro.build.version.sdk"]
146
+ if sdk.isdigit() and int(sdk) < 21:
147
+ raise AdbError("scrcpy needs Android 5.0 (API 21) or newer")
148
+ return {"serial": serial, "name": f'{props["ro.product.manufacturer"]} {props["ro.product.model"]}'.strip(),
149
+ "os": "Android", "version": props["ro.build.version.release"], "sdk": sdk}
150
+
151
+ async def run(self):
152
+ while True:
153
+ try:
154
+ dev = await self.find_device()
155
+ if dev is None:
156
+ self._forget_device()
157
+ await self.wait_viewer_change(2)
158
+ continue
159
+ if self.serial != dev.serial or self.device is None:
160
+ self.device = await self._describe(dev.serial)
161
+ self.serial = dev.serial
162
+ self.generation += 1
163
+ self._fallback = 0
164
+ self.error = None
165
+ self.notify()
166
+ if not self.viewer:
167
+ await self.wait_viewer_change(2)
168
+ continue
169
+ await self._stream(dev.serial)
170
+ except asyncio.CancelledError:
171
+ raise
172
+ except AdbUnavailableError as exc:
173
+ self._forget_device()
174
+ self.error = str(exc)
175
+ except StreamEnded as exc:
176
+ self.error = str(exc) or None
177
+ except (AdbError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError) as exc:
178
+ self.error = f"Android stream stopped ({type(exc).__name__}: {exc}); retrying"
179
+ log.warning(self.error)
180
+ self.notify()
181
+ await asyncio.sleep(1)
182
+
183
+ def _forget_device(self):
184
+ if self.device is not None:
185
+ self.device = None
186
+ self.serial = None
187
+ self.generation += 1
188
+ self.hub.clear()
189
+ self.notify()
190
+
191
+ def _max_size(self):
192
+ if self._fallback:
193
+ size = FALLBACK_SIZES[min(self._fallback, len(FALLBACK_SIZES)) - 1]
194
+ return size if self.settings.max_size == 0 else min(size, self.settings.max_size)
195
+ return self.settings.max_size
196
+
197
+ async def _stream(self, serial):
198
+ self._restart.clear() # before reading settings: a later Apply still restarts
199
+ jar = await asyncio.to_thread(scrcpy.ensure_server, self.settings.server_path)
200
+ await self.adb.push(serial, await asyncio.to_thread(jar.read_bytes), scrcpy.REMOTE_PATH)
201
+ scid = secrets.randbits(31)
202
+ command = scrcpy.server_command(scid, self._max_size(), self.settings.max_fps,
203
+ int(self.settings.bit_rate_mbps * 1_000_000))
204
+ socket_name = f"localabstract:scrcpy_{scid:08x}"
205
+ writers = []
206
+ got_frame = False
207
+ try:
208
+ shell_reader, shell_writer = await self.adb.open_stream(serial, f"shell:{command}")
209
+ writers.append(shell_writer)
210
+ video, video_writer = await self._connect_video(serial, socket_name, shell_reader)
211
+ writers.append(video_writer)
212
+ control_reader, control_writer = await self.adb.open_stream(serial, socket_name)
213
+ writers.append(control_writer)
214
+ await asyncio.wait_for(video.readexactly(scrcpy.DEVICE_NAME_LENGTH), 5)
215
+ codec = int.from_bytes(await asyncio.wait_for(video.readexactly(4), 10), "big")
216
+ self._last_viewer = time.monotonic()
217
+ if codec == scrcpy.CODEC_ERROR:
218
+ raise StreamEnded("The phone's video encoder could not be configured.")
219
+ if codec != scrcpy.CODEC_H264:
220
+ raise StreamEnded("The phone disabled the video stream (is the screen capture blocked?).")
221
+ self.control = asyncio.Queue(CONTROL_QUEUE_LIMIT)
222
+ self.streaming = True
223
+ self.error = None
224
+ self.notify()
225
+ frame_seen = asyncio.Event()
226
+ tasks = [
227
+ asyncio.create_task(self._read_video(video, frame_seen)),
228
+ asyncio.create_task(self._write_control(control_writer)),
229
+ asyncio.create_task(self._drain(control_reader)),
230
+ asyncio.create_task(self._drain(shell_reader, log_lines=True)),
231
+ asyncio.create_task(self._watch_viewer()),
232
+ asyncio.create_task(self._restart.wait()),
233
+ ]
234
+ try:
235
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
236
+ finally:
237
+ got_frame = frame_seen.is_set()
238
+ # Lift held fingers while the control writer can still deliver them.
239
+ await self._release_pointers()
240
+ with contextlib.suppress(asyncio.TimeoutError):
241
+ await asyncio.wait_for(self._control_drained(), 0.5)
242
+ for task in tasks:
243
+ task.cancel()
244
+ await asyncio.gather(*tasks, return_exceptions=True)
245
+ for task in done:
246
+ if not task.cancelled() and task.exception() is not None:
247
+ raise task.exception()
248
+ if not got_frame and not self._restart.is_set() and self.viewer:
249
+ raise StreamEnded("The scrcpy server stopped before sending video.")
250
+ except (StreamEnded, asyncio.IncompleteReadError, asyncio.TimeoutError):
251
+ if not got_frame and self._fallback < len(FALLBACK_SIZES):
252
+ self._fallback += 1
253
+ log.warning("No video at this size; retrying at max_size=%s", self._max_size())
254
+ raise
255
+ finally:
256
+ await self._release_pointers()
257
+ self.streaming = False
258
+ self.control = None
259
+ self.width = self.height = 0
260
+ self._config = b""
261
+ self.hub.clear()
262
+ for writer in writers:
263
+ with contextlib.suppress(Exception):
264
+ writer.close()
265
+ self.notify()
266
+
267
+ async def _connect_video(self, serial, socket_name, shell_reader):
268
+ """The server needs a moment to listen; retry until the dummy byte arrives."""
269
+ deadline = time.monotonic() + 10
270
+ while True:
271
+ try:
272
+ reader, writer = await self.adb.open_stream(serial, socket_name)
273
+ except AdbError:
274
+ reader = writer = None
275
+ if reader is not None:
276
+ try:
277
+ dummy = await asyncio.wait_for(reader.readexactly(1), 2)
278
+ if dummy == b"\x00":
279
+ return reader, writer
280
+ except (asyncio.IncompleteReadError, asyncio.TimeoutError, OSError):
281
+ pass
282
+ writer.close()
283
+ if shell_reader.at_eof():
284
+ raise StreamEnded("The scrcpy server exited during startup.")
285
+ if time.monotonic() > deadline:
286
+ raise StreamEnded("Timed out connecting to the scrcpy server on the phone.")
287
+ await asyncio.sleep(0.1)
288
+
289
+ async def _read_video(self, reader, frame_seen):
290
+ while True:
291
+ header = await reader.readexactly(scrcpy.HEADER_LENGTH)
292
+ parsed = scrcpy.parse_header(header)
293
+ if parsed[0] == "session":
294
+ _, self.width, self.height = parsed
295
+ self.notify()
296
+ continue
297
+ _, config, key, pts, size = parsed
298
+ if size > scrcpy.MAX_PACKET_SIZE:
299
+ raise StreamEnded("Oversized video packet")
300
+ data = await reader.readexactly(size)
301
+ if config:
302
+ self._config = data
303
+ self.hub.set_config({
304
+ "codec": "h264", "codec_string": scrcpy.h264_codec_string(data) or "avc1.42e01f",
305
+ "width": self.width, "height": self.height, "format": "annexb",
306
+ })
307
+ continue
308
+ if key:
309
+ self._last_key = time.monotonic()
310
+ if key and self._config:
311
+ data = self._config + data # parameter sets inline: any key frame is a clean entry point
312
+ frame_seen.set()
313
+ self._fallback = 0
314
+ self.hub.publish_video(data, key, pts)
315
+
316
+ async def _write_control(self, writer):
317
+ while True:
318
+ message = await self.control.get()
319
+ writer.write(message)
320
+ await asyncio.wait_for(writer.drain(), 5)
321
+
322
+ @staticmethod
323
+ async def _drain(reader, log_lines=False):
324
+ while True:
325
+ data = await reader.read(4096)
326
+ if not data:
327
+ raise StreamEnded("The scrcpy server closed the connection.")
328
+ if log_lines:
329
+ for line in data.decode("utf-8", "replace").splitlines():
330
+ if line.strip():
331
+ log.info("scrcpy-server: %s", line.strip()[:200])
332
+
333
+ async def _watch_viewer(self):
334
+ while True:
335
+ if self.viewer:
336
+ self._last_viewer = time.monotonic()
337
+ elif time.monotonic() - self._last_viewer > VIEWER_GRACE_SECONDS:
338
+ return
339
+ await self.wait_viewer_change(1)
340
+
341
+ # -- input -------------------------------------------------------------------------
342
+
343
+ def _send(self, message):
344
+ if self.control is None:
345
+ return {"error": "Not connected to the phone"}
346
+ try:
347
+ self.control.put_nowait(message)
348
+ except asyncio.QueueFull:
349
+ return {"error": "Control channel busy"}
350
+ return None
351
+
352
+ async def _control_drained(self):
353
+ while self.control is not None and not self.control.empty():
354
+ await asyncio.sleep(0.02)
355
+
356
+ def _request_keyframe(self):
357
+ # RESET_VIDEO restarts the phone's encoder. Key frames already arrive every
358
+ # 2 s (i-frame-interval), so only reset when they have stopped coming.
359
+ if time.monotonic() - self._last_key < 2.5:
360
+ return
361
+ if self.control is not None:
362
+ with contextlib.suppress(asyncio.QueueFull):
363
+ self.control.put_nowait(scrcpy.simple_command("reset_video"))
364
+
365
+ def _point(self, x, y):
366
+ w, h = self.width, self.height
367
+ return round(x * (w - 1) / COORD_MAX), round(y * (h - 1) / COORD_MAX), w, h
368
+
369
+ async def _release_pointers(self):
370
+ if self.control is not None and self.width:
371
+ for pid, (x, y) in list(self.pointers.items()):
372
+ with contextlib.suppress(asyncio.QueueFull):
373
+ self.control.put_nowait(scrcpy.inject_touch(scrcpy.ACTION_UP, x, y, self.width, self.height, 0, pid))
374
+ self.pointers.clear()
375
+
376
+ async def set_viewer(self, present):
377
+ if not present:
378
+ await self._release_pointers()
379
+ await super().set_viewer(present)
380
+
381
+ async def handle_input(self, event):
382
+ if self.control is None or not self.width:
383
+ return {"error": "Video not started yet"}
384
+ kind = event["type"]
385
+ if kind == "touch":
386
+ x, y, w, h = self._point(event["x"], event["y"])
387
+ pid = event["id"]
388
+ phase = event["phase"]
389
+ if phase == "down":
390
+ action, pressure = scrcpy.ACTION_DOWN, 1.0
391
+ self.pointers[pid] = (x, y)
392
+ elif phase == "move":
393
+ if pid not in self.pointers:
394
+ return None
395
+ action, pressure = scrcpy.ACTION_MOVE, 1.0
396
+ self.pointers[pid] = (x, y)
397
+ else:
398
+ if self.pointers.pop(pid, None) is None:
399
+ return None
400
+ action, pressure = scrcpy.ACTION_UP, 0.0
401
+ return self._send(scrcpy.inject_touch(action, x, y, w, h, pressure, pid))
402
+ if kind == "scroll":
403
+ x, y, w, h = self._point(event["x"], event["y"])
404
+ return self._send(scrcpy.inject_scroll(x, y, w, h, event["dx"], event["dy"]))
405
+ if kind == "key":
406
+ return self._key(event["key"], event["phase"])
407
+ if kind == "text":
408
+ text = event["text"]
409
+ if text.isascii() and len(text.encode()) <= scrcpy.INJECT_TEXT_MAX_LENGTH:
410
+ return self._send(scrcpy.inject_text(text))
411
+ # Non-ASCII characters have no key events; paste them via the clipboard.
412
+ return self._send(scrcpy.set_clipboard(text, paste=True))
413
+ if kind == "command":
414
+ name = event["name"]
415
+ if name in {"screen_off", "screen_on"}:
416
+ return self._send(scrcpy.set_display_power(name == "screen_on"))
417
+ return self._send(scrcpy.simple_command(name))
418
+ return {"error": "Unsupported event"}
419
+
420
+ def _key(self, key, phase):
421
+ actions = {"press": (scrcpy.ACTION_DOWN, scrcpy.ACTION_UP), "down": (scrcpy.ACTION_DOWN,),
422
+ "up": (scrcpy.ACTION_UP,)}[phase]
423
+ for action in actions:
424
+ if key == "back":
425
+ message = scrcpy.back_or_screen_on(action)
426
+ else:
427
+ code = 219 if key == "siri" else scrcpy.KEYCODES[key] # KEYCODE_ASSIST
428
+ message = scrcpy.inject_keycode(action, code)
429
+ error = self._send(message)
430
+ if error:
431
+ return error
432
+ return None