#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ LihonT ADB Phone Controller (Mini-App) Control your Android phone with mouse clicks, gestures, hotkeys, and color eyedropper over ADB. """ from __future__ import annotations import io import os import queue import re import subprocess import sys import threading import time from pathlib import Path try: import customtkinter as ctk from PIL import Image, ImageTk except ImportError as err: print(f"Missing required GUI package: {err}") print("Please run: pip install customtkinter pillow") sys.exit(1) # Default ADB locations on Windows ADB_CANDIDATES = [ os.path.expandvars(r"%LOCALAPPDATA%\Android\Sdk\platform-tools\adb.exe"), "adb", r"C:\platform-tools\adb.exe", ] def find_adb() -> str: for candidate in ADB_CANDIDATES: try: res = subprocess.run([candidate, "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if res.returncode == 0: return candidate except Exception: continue return "adb" ADB_BIN = find_adb() class AdbDeviceManager: """Manages ADB commands and worker thread queue.""" def __init__(self, adb_path: str = ADB_BIN): self.adb = adb_path self.cmd_queue: queue.Queue[list[str]] = queue.Queue() self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True) self.worker_thread.start() def _worker_loop(self) -> None: while True: cmd = self.cmd_queue.get() try: subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: print("ADB worker error:", e) finally: self.cmd_queue.task_done() def list_devices(self) -> list[str]: try: res = subprocess.run([self.adb, "devices"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) lines = res.stdout.strip().splitlines() devices = [] for line in lines[1:]: parts = line.strip().split() if len(parts) >= 2 and parts[1] == "device": devices.append(parts[0]) return devices except Exception as e: print("Error listing devices:", e) return [] def get_screen_size(self, serial: str) -> tuple[int, int]: try: res = subprocess.run([self.adb, "-s", serial, "shell", "wm", "size"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) match = re.search(r"(\d+)x(\d+)", res.stdout) if match: return int(match.group(1)), int(match.group(2)) except Exception as e: print("Error getting screen size:", e) return (1080, 2400) def capture_screen(self, serial: str) -> Image.Image | None: try: proc = subprocess.run([self.adb, "-s", serial, "exec-out", "screencap", "-p"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if proc.returncode == 0 and len(proc.stdout) > 1000: return Image.open(io.BytesIO(proc.stdout)) except Exception as e: pass return None def tap(self, serial: str, x: int, y: int) -> None: self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "tap", str(x), str(y)]) def swipe(self, serial: str, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> None: self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration_ms)]) def keyevent(self, serial: str, code: int | str) -> None: self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "keyevent", str(code)]) def input_text(self, serial: str, text: str) -> None: escaped = text.replace(" ", "%s").replace("&", "\\&").replace("<", "\\<").replace(">", "\\>") self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "text", escaped]) def connect_wifi(self, address: str) -> tuple[bool, str]: try: res = subprocess.run([self.adb, "connect", address], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) return ("connected" in res.stdout.lower(), res.stdout.strip()) except Exception as e: return (False, str(e)) class PhoneControllerApp(ctk.CTk): def __init__(self): super().__init__() self.title("LihonT ADB Phone Controller") self.geometry("960x940") self.minsize(750, 750) ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") self.mgr = AdbDeviceManager() self.current_serial: str | None = None self.native_w: int = 1080 self.native_h: int = 2400 # Viewport geometry cache self.img_disp_x: int = 0 self.img_disp_y: int = 0 self.img_disp_w: int = 0 self.img_disp_h: int = 0 # Interaction tracking self.drag_start_x: int | None = None self.drag_start_y: int | None = None self.drag_start_time: float = 0.0 # Eyedropper (Color Picker) state self.picker_mode: bool = False self.latest_frame: Image.Image | None = None self.picked_color: str = "#D0BCFF" self.picked_rgb: str = "rgb(208, 188, 255)" self.color_history: list[str] = ["#D0BCFF", "#381E72", "#141218", "#E6E0E9", "#4F378B", "#8C1D40"] self.loupe_photo: ImageTk.PhotoImage | None = None # Threading control self.is_streaming = True self.photo_image: ImageTk.PhotoImage | None = None self.pending_frame: Image.Image | None = None self.frame_lock = threading.Lock() self._build_ui() self._refresh_device_list() # Start background screen grab loop self.stream_thread = threading.Thread(target=self._stream_loop, daemon=True) self.stream_thread.start() # Periodic UI update (30 FPS check) self.after(33, self._update_canvas_loop) def _build_ui(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=0) self.grid_rowconfigure(0, weight=1) # ------------------- LEFT: Phone Viewport ------------------- self.left_frame = ctk.CTkFrame(self, corner_radius=16, fg_color="#141218") self.left_frame.grid(row=0, column=0, padx=12, pady=12, sticky="nsew") self.left_frame.grid_rowconfigure(0, weight=1) self.left_frame.grid_columnconfigure(0, weight=1) self.canvas = ctk.CTkCanvas(self.left_frame, bg="#141218", highlightthickness=0, cursor="hand2") self.canvas.grid(row=0, column=0, padx=8, pady=8, sticky="nsew") # Mouse event bindings self.canvas.bind("", self._on_canvas_press) self.canvas.bind("", self._on_canvas_release) self.canvas.bind("", self._on_canvas_motion) self.canvas.bind("", self._on_canvas_right_click) self.canvas.bind("", self._on_canvas_mousewheel) self.canvas.bind("", self._on_canvas_double_click) self.canvas.bind("", self._on_canvas_resize) # Global hotkeys self.bind("", lambda e: self._on_esc()) self.bind("", lambda e: self._on_paste()) self.bind("

", lambda e: self._toggle_picker_mode()) self.bind("

", lambda e: self._toggle_picker_mode()) self.bind("", lambda e: self._toggle_picker_mode()) self.bind("", lambda e: self._toggle_picker_mode()) # ------------------- RIGHT: Controls Sidebar ------------------- self.side_frame = ctk.CTkScrollableFrame(self, width=340, corner_radius=16, fg_color="#1E1A22") self.side_frame.grid(row=0, column=1, padx=(0, 12), pady=12, sticky="nsew") # Header Title title_label = ctk.CTkLabel( self.side_frame, text="LihonT Remote", font=ctk.CTkFont(size=22, weight="bold"), text_color="#D0BCFF" ) title_label.pack(pady=(10, 2), anchor="w") sub_label = ctk.CTkLabel( self.side_frame, text="Управление кликом, свайпами и пипетка цвета по ADB", font=ctk.CTkFont(size=12), text_color="#CAC4D0" ) sub_label.pack(pady=(0, 12), anchor="w") # Device selection card dev_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") dev_card.pack(fill="x", pady=6) ctk.CTkLabel(dev_card, text="Устройство ADB:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 4)) self.dev_dropdown = ctk.CTkComboBox(dev_card, values=["Поиск..."], command=self._on_select_device) self.dev_dropdown.pack(fill="x", padx=12, pady=(0, 8)) btn_row = ctk.CTkFrame(dev_card, fg_color="transparent") btn_row.pack(fill="x", padx=12, pady=(0, 10)) btn_row.grid_columnconfigure(0, weight=1) btn_row.grid_columnconfigure(1, weight=1) self.btn_refresh = ctk.CTkButton(btn_row, text="Обновить", command=self._refresh_device_list, height=28, fg_color="#4F378B", hover_color="#654B9E") self.btn_refresh.grid(row=0, column=0, padx=(0, 4), sticky="ew") self.btn_wifi = ctk.CTkButton(btn_row, text="Wi-Fi ADB", command=self._open_wifi_dialog, height=28, fg_color="#382E47", hover_color="#493D5C") self.btn_wifi.grid(row=0, column=1, padx=(4, 0), sticky="ew") self.status_lbl = ctk.CTkLabel(dev_card, text="Ожидание устройства...", text_color="#E0A040", font=ctk.CTkFont(size=12)) self.status_lbl.pack(anchor="w", padx=12, pady=(0, 10)) # ------------------- Color Eyedropper Card ------------------- self.picker_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") self.picker_card.pack(fill="x", pady=8) picker_header = ctk.CTkFrame(self.picker_card, fg_color="transparent") picker_header.pack(fill="x", padx=12, pady=(10, 4)) ctk.CTkLabel( picker_header, text="Пипетка цвета (Eyedropper):", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9" ).pack(side="left") self.picker_badge = ctk.CTkLabel( picker_header, text="ВЫКЛ", font=ctk.CTkFont(size=10, weight="bold"), text_color="#CAC4D0", fg_color="#382E47", corner_radius=6, width=54, height=20 ) self.picker_badge.pack(side="right") self.btn_picker = ctk.CTkButton( self.picker_card, text="Включить пипетку [P]", command=self._toggle_picker_mode, fg_color="#4F378B", hover_color="#654B9E", height=32, font=ctk.CTkFont(size=12, weight="bold") ) self.btn_picker.pack(fill="x", padx=12, pady=4) inspect_frame = ctk.CTkFrame(self.picker_card, fg_color="#1E1A22", corner_radius=8) inspect_frame.pack(fill="x", padx=12, pady=6) inspect_frame.grid_columnconfigure(0, weight=0) inspect_frame.grid_columnconfigure(1, weight=1) # Magnifier loupe canvas self.loupe_canvas = ctk.CTkCanvas( inspect_frame, width=100, height=100, bg="#141218", highlightthickness=1, highlightbackground="#4F378B" ) self.loupe_canvas.grid(row=0, column=0, padx=8, pady=8) # Color info info_col = ctk.CTkFrame(inspect_frame, fg_color="transparent") info_col.grid(row=0, column=1, padx=(0, 8), pady=8, sticky="nsew") swatch_row = ctk.CTkFrame(info_col, fg_color="transparent") swatch_row.pack(fill="x", pady=(2, 4)) self.swatch_view = ctk.CTkFrame(swatch_row, width=28, height=28, corner_radius=6, fg_color=self.picked_color) self.swatch_view.pack(side="left", padx=(0, 6)) self.swatch_view.pack_propagate(False) self.hex_label = ctk.CTkLabel( swatch_row, text=self.picked_color, font=ctk.CTkFont(size=15, weight="bold"), text_color="#FFFFFF", cursor="hand2" ) self.hex_label.pack(side="left", anchor="w") self.hex_label.bind("", lambda e: self._copy_color(self.picked_color)) self.rgb_label = ctk.CTkLabel( info_col, text=self.picked_rgb, font=ctk.CTkFont(size=11), text_color="#CAC4D0", anchor="w" ) self.rgb_label.pack(fill="x", pady=(0, 2)) self.coords_label = ctk.CTkLabel( info_col, text="X: --- Y: ---", font=ctk.CTkFont(size=11), text_color="#938F99", anchor="w" ) self.coords_label.pack(fill="x", pady=(0, 2)) copy_row = ctk.CTkFrame(self.picker_card, fg_color="transparent") copy_row.pack(fill="x", padx=12, pady=4) copy_row.grid_columnconfigure(0, weight=1) copy_row.grid_columnconfigure(1, weight=1) self.btn_copy_hex = ctk.CTkButton( copy_row, text="Копировать HEX", command=lambda: self._copy_color(self.picked_color), height=28, fg_color="#382E47", hover_color="#493D5C", font=ctk.CTkFont(size=11) ) self.btn_copy_hex.grid(row=0, column=0, padx=(0, 4), sticky="ew") self.btn_copy_rgb = ctk.CTkButton( copy_row, text="Копировать RGB", command=lambda: self._copy_color(self.picked_rgb), height=28, fg_color="#382E47", hover_color="#493D5C", font=ctk.CTkFont(size=11) ) self.btn_copy_rgb.grid(row=0, column=1, padx=(4, 0), sticky="ew") ctk.CTkLabel( self.picker_card, text="История цветов (клик для копирования):", font=ctk.CTkFont(size=11), text_color="#CAC4D0" ).pack(anchor="w", padx=12, pady=(6, 2)) self.history_frame = ctk.CTkFrame(self.picker_card, fg_color="transparent") self.history_frame.pack(fill="x", padx=12, pady=(0, 10)) self._refresh_history_ui() # ------------------- TikTok Actions Card ------------------- tt_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") tt_card.pack(fill="x", pady=8) ctk.CTkLabel(tt_card, text="Жесты TikTok / Видео:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) tt_btn_up = ctk.CTkButton(tt_card, text="Следующее видео (Свайп вверх)", command=self._swipe_up, fg_color="#6750A4", hover_color="#7F67BE") tt_btn_up.pack(fill="x", padx=12, pady=4) tt_btn_down = ctk.CTkButton(tt_card, text="Предыдущее видео", command=self._swipe_down, fg_color="#382E47", hover_color="#493D5C") tt_btn_down.pack(fill="x", padx=12, pady=4) tt_btn_like = ctk.CTkButton(tt_card, text="Лайк (Двойной тап)", command=self._double_tap_center, fg_color="#8C1D40", hover_color="#B32B52") tt_btn_like.pack(fill="x", padx=12, pady=(4, 10)) # ------------------- System Navigation Card ------------------- nav_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") nav_card.pack(fill="x", pady=8) ctk.CTkLabel(nav_card, text="Системная навигация:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) nav_row1 = ctk.CTkFrame(nav_card, fg_color="transparent") nav_row1.pack(fill="x", padx=12, pady=4) nav_row1.grid_columnconfigure(0, weight=1) nav_row1.grid_columnconfigure(1, weight=1) nav_row1.grid_columnconfigure(2, weight=1) ctk.CTkButton(nav_row1, text="Назад", command=self._on_back, fg_color="#4F378B").grid(row=0, column=0, padx=2, sticky="ew") ctk.CTkButton(nav_row1, text="Домой", command=self._on_home, fg_color="#4F378B").grid(row=0, column=1, padx=2, sticky="ew") ctk.CTkButton(nav_row1, text="Меню", command=self._on_recents, fg_color="#4F378B").grid(row=0, column=2, padx=2, sticky="ew") nav_row2 = ctk.CTkFrame(nav_card, fg_color="transparent") nav_row2.pack(fill="x", padx=12, pady=(4, 10)) nav_row2.grid_columnconfigure(0, weight=1) nav_row2.grid_columnconfigure(1, weight=1) nav_row2.grid_columnconfigure(2, weight=1) ctk.CTkButton(nav_row2, text="Громкость -", command=lambda: self.mgr.keyevent(self.current_serial, 25), fg_color="#382E47").grid(row=0, column=0, padx=2, sticky="ew") ctk.CTkButton(nav_row2, text="Громкость +", command=lambda: self.mgr.keyevent(self.current_serial, 24), fg_color="#382E47").grid(row=0, column=1, padx=2, sticky="ew") ctk.CTkButton(nav_row2, text="Экран", command=lambda: self.mgr.keyevent(self.current_serial, 26), fg_color="#382E47").grid(row=0, column=2, padx=2, sticky="ew") # ------------------- Text Typing Card ------------------- txt_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") txt_card.pack(fill="x", pady=8) ctk.CTkLabel(txt_card, text="Ввод текста на телефоне:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) self.text_entry = ctk.CTkEntry(txt_card, placeholder_text="Текст для отправки...") self.text_entry.pack(fill="x", padx=12, pady=4) self.text_entry.bind("", lambda e: self._send_text()) btn_send_txt = ctk.CTkButton(txt_card, text="Отправить в телефон", command=self._send_text, fg_color="#6750A4", hover_color="#7F67BE") btn_send_txt.pack(fill="x", padx=12, pady=(4, 10)) # ------------------- Utilities Card ------------------- util_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") util_card.pack(fill="x", pady=8) ctk.CTkLabel(util_card, text="Дополнительно:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) btn_shot = ctk.CTkButton(util_card, text="Сохранить скриншот", command=self._save_screenshot, fg_color="#382E47", hover_color="#493D5C") btn_shot.pack(fill="x", padx=12, pady=(4, 10)) # Hint box hint_label = ctk.CTkLabel( self.side_frame, text="Подсказки:\n• Клавиша P или C: Режим пипетки\n• Зажать Alt: Быстрый забор цвета кликом\n• ЛКМ: Клик / Тап по экрану\n• Перетаскивание: Свайп\n• Колёсико мыши: Прокрутка\n• ПКМ или Esc: Кнопка «Назад»\n• Двойной клик: Лайк в TikTok", font=ctk.CTkFont(size=11), text_color="#938F99", justify="left" ) hint_label.pack(anchor="w", padx=6, pady=(10, 10)) # ------------------- Device Management ------------------- def _refresh_device_list(self) -> None: devices = self.mgr.list_devices() if devices: self.dev_dropdown.configure(values=devices) if self.current_serial not in devices: self.current_serial = devices[0] self.dev_dropdown.set(self.current_serial) self._update_device_info() self.status_lbl.configure(text=f"Подключено: {self.current_serial}", text_color="#80E89E") else: self.current_serial = None self.dev_dropdown.configure(values=["Нет устройств"]) self.dev_dropdown.set("Нет устройств") self.status_lbl.configure(text="Ожидание подключения USB / Wi-Fi...", text_color="#E0A040") def _on_select_device(self, choice: str) -> None: if choice and choice != "Нет устройств": self.current_serial = choice self._update_device_info() def _update_device_info(self) -> None: if not self.current_serial: return nw, nh = self.mgr.get_screen_size(self.current_serial) self.native_w = nw self.native_h = nh print(f"Device {self.current_serial} screen resolution: {nw}x{nh}") def _open_wifi_dialog(self) -> None: dialog = ctk.CTkInputDialog(text="Введите IP и порт (например 192.168.1.105:5555):", title="Wi-Fi ADB Connect") ip = dialog.get_input() if ip: ok, msg = self.mgr.connect_wifi(ip.strip()) time.sleep(0.5) self._refresh_device_list() # ------------------- Eyedropper / Color Picker ------------------- def _toggle_picker_mode(self) -> None: self.picker_mode = not self.picker_mode if self.picker_mode: self.canvas.configure(cursor="crosshair") self.picker_badge.configure(text="АКТИВНА", fg_color="#8C1D40", text_color="#FFD8E4") self.btn_picker.configure(text="Выключить пипетку [P]", fg_color="#8C1D40", hover_color="#B32B52") self.status_lbl.configure(text="Пипетка активна: наведите на экран и кликните", text_color="#D0BCFF") else: self.canvas.configure(cursor="hand2") self.picker_badge.configure(text="ВЫКЛ", fg_color="#382E47", text_color="#CAC4D0") self.btn_picker.configure(text="Включить пипетку [P]", fg_color="#4F378B", hover_color="#654B9E") self.status_lbl.configure(text="Пипетка выключена", text_color="#CAC4D0") def _sample_color_at(self, px: int, py: int, from_click: bool = False) -> tuple[str, str] | None: if not self.latest_frame: return None fw, fh = self.latest_frame.size if not (0 <= px < fw and 0 <= py < fh): return None try: pixel = self.latest_frame.getpixel((px, py)) if isinstance(pixel, (tuple, list)): r, g, b = pixel[0], pixel[1], pixel[2] else: r = g = b = int(pixel) except Exception: return None hex_color = f"#{r:02X}{g:02X}{b:02X}" rgb_color = f"rgb({r}, {g}, {b})" self.picked_color = hex_color self.picked_rgb = rgb_color self.swatch_view.configure(fg_color=hex_color) self.hex_label.configure(text=hex_color) self.rgb_label.configure(text=rgb_color) self.coords_label.configure(text=f"X: {px} Y: {py}") self._update_loupe(px, py, fw, fh) if from_click: self._copy_color(hex_color) self._add_to_history(hex_color) self.status_lbl.configure(text=f"Цвет скопирован: {hex_color} ({rgb_color})", text_color="#80E89E") return hex_color, rgb_color def _update_loupe(self, px: int, py: int, fw: int, fh: int) -> None: if not self.latest_frame: return r = 5 x1 = max(0, px - r) y1 = max(0, py - r) x2 = min(fw, px + r + 1) y2 = min(fh, py + r + 1) patch = self.latest_frame.crop((x1, y1, x2, y2)) loupe_size = 100 resized = patch.resize((loupe_size, loupe_size), Image.Resampling.NEAREST) self.loupe_photo = ImageTk.PhotoImage(resized) self.loupe_canvas.delete("all") self.loupe_canvas.create_image(0, 0, anchor="nw", image=self.loupe_photo) cx, cy = loupe_size // 2, loupe_size // 2 pixel_box_half = (loupe_size / (2 * r + 1)) / 2 self.loupe_canvas.create_rectangle( cx - pixel_box_half - 1, cy - pixel_box_half - 1, cx + pixel_box_half + 1, cy + pixel_box_half + 1, outline="#000000", width=2 ) self.loupe_canvas.create_rectangle( cx - pixel_box_half, cy - pixel_box_half, cx + pixel_box_half, cy + pixel_box_half, outline="#FFFFFF", width=2 ) self.loupe_canvas.create_line(cx, 0, cx, cy - pixel_box_half - 2, fill="#FFFFFF", width=1) self.loupe_canvas.create_line(cx, cy + pixel_box_half + 2, cx, loupe_size, fill="#FFFFFF", width=1) self.loupe_canvas.create_line(0, cy, cx - pixel_box_half - 2, cy, fill="#FFFFFF", width=1) self.loupe_canvas.create_line(cx + pixel_box_half + 2, cy, loupe_size, cy, fill="#FFFFFF", width=1) def _copy_color(self, val: str) -> None: try: self.clipboard_clear() self.clipboard_append(val) self.update() self.status_lbl.configure(text=f"Скопировано в буфер: {val}", text_color="#D0BCFF") except Exception as e: print("Clipboard error:", e) def _add_to_history(self, hex_color: str) -> None: if hex_color in self.color_history: self.color_history.remove(hex_color) self.color_history.insert(0, hex_color) if len(self.color_history) > 8: self.color_history = self.color_history[:8] self._refresh_history_ui() def _refresh_history_ui(self) -> None: for child in self.history_frame.winfo_children(): child.destroy() for col in self.color_history: btn = ctk.CTkButton( self.history_frame, text="", width=26, height=26, corner_radius=6, fg_color=col, hover_color=col, command=lambda c=col: self._select_history_color(c) ) btn.pack(side="left", padx=2, pady=2) def _select_history_color(self, hex_color: str) -> None: self.picked_color = hex_color self.swatch_view.configure(fg_color=hex_color) self.hex_label.configure(text=hex_color) try: hc = hex_color.lstrip("#") r, g, b = tuple(int(hc[i:i+2], 16) for i in (0, 2, 4)) self.picked_rgb = f"rgb({r}, {g}, {b})" self.rgb_label.configure(text=self.picked_rgb) except Exception: pass self._copy_color(hex_color) # ------------------- Background Stream & Rendering ------------------- def _stream_loop(self) -> None: while self.is_streaming: if not self.current_serial: time.sleep(1.0) continue frame = self.mgr.capture_screen(self.current_serial) if frame: with self.frame_lock: self.pending_frame = frame time.sleep(0.04) else: time.sleep(0.5) def _update_canvas_loop(self) -> None: with self.frame_lock: frame = self.pending_frame self.pending_frame = None if frame: self.latest_frame = frame cw = self.canvas.winfo_width() ch = self.canvas.winfo_height() if cw > 50 and ch > 50: fw, fh = frame.size scale = min(cw / fw, ch / fh) disp_w = int(fw * scale) disp_h = int(fh * scale) disp_x = (cw - disp_w) // 2 disp_y = (ch - disp_h) // 2 self.img_disp_x = disp_x self.img_disp_y = disp_y self.img_disp_w = disp_w self.img_disp_h = disp_h resized = frame.resize((disp_w, disp_h), Image.Resampling.BILINEAR) self.photo_image = ImageTk.PhotoImage(resized) self.canvas.delete("all") self.canvas.create_image(disp_x, disp_y, anchor="nw", image=self.photo_image) elif not self.current_serial: cw = self.canvas.winfo_width() ch = self.canvas.winfo_height() if cw > 50 and ch > 50: self.canvas.delete("all") self.canvas.create_text( cw // 2, ch // 2, text="Подключите Android по USB или Wi-Fi\nи включите «Отладку по USB» в настройках", fill="#CAC4D0", font=("Segoe UI", 14), justify="center" ) self.after(40, self._update_canvas_loop) def _on_canvas_resize(self, event) -> None: pass # ------------------- Coordinate Mapping & Events ------------------- def _canvas_to_phone(self, cx: int, cy: int) -> tuple[int, int] | None: if self.img_disp_w <= 0 or self.img_disp_h <= 0: return None rel_x = cx - self.img_disp_x rel_y = cy - self.img_disp_y if 0 <= rel_x <= self.img_disp_w and 0 <= rel_y <= self.img_disp_h: px = int(rel_x / self.img_disp_w * self.native_w) py = int(rel_y / self.img_disp_h * self.native_h) return max(0, min(self.native_w - 1, px)), max(0, min(self.native_h - 1, py)) return None def _on_canvas_motion(self, event) -> None: coords = self._canvas_to_phone(event.x, event.y) if coords: px, py = coords is_alt_pressed = bool(event.state & 0x0008) or bool(event.state & 0x20000) if self.picker_mode or is_alt_pressed: self._sample_color_at(px, py, from_click=False) def _on_canvas_press(self, event) -> None: coords = self._canvas_to_phone(event.x, event.y) if coords: px, py = coords is_alt_pressed = bool(event.state & 0x0008) or bool(event.state & 0x20000) if self.picker_mode or is_alt_pressed: self._sample_color_at(px, py, from_click=True) return self.drag_start_x, self.drag_start_y = coords self.drag_start_time = time.time() def _on_canvas_release(self, event) -> None: if self.picker_mode: return if not self.current_serial or self.drag_start_x is None or self.drag_start_y is None: return end_coords = self._canvas_to_phone(event.x, event.y) if not end_coords: end_coords = (self.drag_start_x, self.drag_start_y) end_x, end_y = end_coords dx = abs(end_x - self.drag_start_x) dy = abs(end_y - self.drag_start_y) duration_ms = int((time.time() - self.drag_start_time) * 1000) if dx < 25 and dy < 25: self.mgr.tap(self.current_serial, self.drag_start_x, self.drag_start_y) else: dur = max(120, min(1200, duration_ms)) self.mgr.swipe(self.current_serial, self.drag_start_x, self.drag_start_y, end_x, end_y, dur) self.drag_start_x = None self.drag_start_y = None def _on_canvas_right_click(self, event) -> None: self._on_back() def _on_canvas_double_click(self, event) -> None: if self.picker_mode: return coords = self._canvas_to_phone(event.x, event.y) if coords and self.current_serial: px, py = coords self.mgr.tap(self.current_serial, px, py) time.sleep(0.08) self.mgr.tap(self.current_serial, px, py) def _on_canvas_mousewheel(self, event) -> None: if not self.current_serial or self.picker_mode: return cx = self.native_w // 2 top_y = int(self.native_h * 0.25) bot_y = int(self.native_h * 0.75) if event.delta < 0: self.mgr.swipe(self.current_serial, cx, bot_y, cx, top_y, 250) else: self.mgr.swipe(self.current_serial, cx, top_y, cx, bot_y, 250) # ------------------- Button Action Handlers ------------------- def _on_esc(self) -> None: if self.picker_mode: self._toggle_picker_mode() else: self._on_back() def _on_back(self) -> None: if self.current_serial: self.mgr.keyevent(self.current_serial, 4) def _on_home(self) -> None: if self.current_serial: self.mgr.keyevent(self.current_serial, 3) def _on_recents(self) -> None: if self.current_serial: self.mgr.keyevent(self.current_serial, 187) def _swipe_up(self) -> None: if self.current_serial: cx = self.native_w // 2 self.mgr.swipe(self.current_serial, cx, int(self.native_h * 0.80), cx, int(self.native_h * 0.20), 220) def _swipe_down(self) -> None: if self.current_serial: cx = self.native_w // 2 self.mgr.swipe(self.current_serial, cx, int(self.native_h * 0.25), cx, int(self.native_h * 0.80), 220) def _double_tap_center(self) -> None: if self.current_serial: cx = self.native_w // 2 cy = self.native_h // 2 self.mgr.tap(self.current_serial, cx, cy) time.sleep(0.08) self.mgr.tap(self.current_serial, cx, cy) def _send_text(self) -> None: txt = self.text_entry.get() if txt and self.current_serial: self.mgr.input_text(self.current_serial, txt) self.text_entry.delete(0, "end") def _on_paste(self) -> None: try: clip = self.clipboard_get() if clip and self.current_serial: self.mgr.input_text(self.current_serial, clip) except Exception: pass def _save_screenshot(self) -> None: if not self.current_serial: return frame = self.mgr.capture_screen(self.current_serial) if frame: dt = time.strftime("%Y%m%d_%H%M%S") desktop = (Path.home() / r"Desktop") out_file = desktop / f"screenshot_{dt}.png" frame.save(out_file) print("Saved screenshot to:", out_file) self.status_lbl.configure(text=f"Скриншот сохранён: {out_file.name}", text_color="#D0BCFF") def main() -> None: app = PhoneControllerApp() app.mainloop() if __name__ == "__main__": main()