Files
LihonT/tools/adb_controller.py
cheb0chik ce1d870d54 LihonT 2 Luna beta: source release
Mod for TikTok 47.1.3 (arm64) in Material 3 style: bottom bar, profile menu, TikTok settings and
comments; downloads without the watermark; switches for ads, live streams and the Shop tab; badges of
other mods next to names and the niche mod badge (members service); app icons; mod settings screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:32:13 +03:00

835 lines
34 KiB
Python

#!/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("<Button-1>", self._on_canvas_press)
self.canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
self.canvas.bind("<Motion>", self._on_canvas_motion)
self.canvas.bind("<Button-3>", self._on_canvas_right_click)
self.canvas.bind("<MouseWheel>", self._on_canvas_mousewheel)
self.canvas.bind("<Double-Button-1>", self._on_canvas_double_click)
self.canvas.bind("<Configure>", self._on_canvas_resize)
# Global hotkeys
self.bind("<Escape>", lambda e: self._on_esc())
self.bind("<Control-v>", lambda e: self._on_paste())
self.bind("<p>", lambda e: self._toggle_picker_mode())
self.bind("<P>", lambda e: self._toggle_picker_mode())
self.bind("<c>", lambda e: self._toggle_picker_mode())
self.bind("<C>", 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("<Button-1>", 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("<Return>", 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()