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>
This commit is contained in:
834
tools/adb_controller.py
Normal file
834
tools/adb_controller.py
Normal file
@@ -0,0 +1,834 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user