Files
ykxmz/_build/android_v230.py
T
xmz7891 2061f9525f 2.3.0:手表界面精简 + 播放器 + 鼠标重做
手表端
- 功能页从 10 个杂项精简到 7 个(屏幕/鼠标/媒体/文件/电脑/语音/电源)
- 删掉:游戏封锁、待办、掷骰子、抽奖、壁纸、显示器开关、定时关机、
  剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制等
- 改成显式列按钮,不再把电脑端所有功能组无脑铺到表盘
- 远程鼠标控制重做:滑动区 + 左键/滚轮/右键三按钮
  滚轮点按=中键、上下滑=滚动
- 新增视频/音乐播放器(手表本地播放):进度拖动、左右滑快进退10秒、
  点画面播放暂停、上一/下一、播完自动下一首

电脑端
- 新增 kill_all(结束所有进程),白名单保护系统关键进程和自身
- 新增 media_list / media_pull(播放器数据源)
  安全边界:只允许媒体目录,用 relative_to 判断,防同前缀兄弟目录绕过

中转站
- known_devices 从 ID 集合改成「设备ID -> 配对码」映射
- 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉
- 老格式 state 文件平滑升级
2026-10-18 11:39:14 +08:00

1172 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''安卓端 2.3.0:UI 精简 + 播放器 + 鼠标面板重做。
== 用户要求(原话整理)==
「杂七杂八功能都删掉」,只保留:
音量加减 / 播放 屏幕镜像 立即返回桌面
调整屏幕亮度 发送文件 打开回收站
打开各种程序 语音播报 打开网址
快捷命令 进程管理 关机/重启/睡眠/锁屏
当前窗口置顶 一键结束所有进程
再加:
* 远程鼠标控制(中间滑动区 + 下面三个按钮:左键 / 滚轮 / 右键)
* 视频播放器 + 音乐播放器(暂停开始、进度左右划、上一个下一个)
== 改动方式 ==
**全部用精确锚点替换,替换前后都做断言。**
这个项目里"脚本说改了、其实锚点没匹配上"的坑踩过不止一次
(最典型的是 nginx 那次逐行注释把括号配平搞坏)。
所以这里的规矩是:
* 锚点必须**唯一**
* 替换后必须能在结果里找到新内容
* 任何一步对不上就抛异常,**绝不跳过**
== 为什么不用 renderGroups 了 ==
原来每个分页靠 `TabDef.groups` 动态渲染电脑端上报的功能组。
那样"电脑端有什么功能,手表上就出一堆按钮"——正是用户嫌乱的东西。
现在改成**显式列按钮**:手表上有什么,完全由这份代码决定。
(电脑端功能还在,只是不再无脑铺到表盘上。)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
JAVA = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
def must_replace(text: str, old: str, new: str, label: str) -> str:
"""精确替换,锚点必须唯一出现。"""
count = text.count(old)
if count == 0:
raise SystemExit("✗ [%s] 锚点没找到" % label)
if count > 1:
raise SystemExit("✗ [%s] 锚点出现 %d 次,不唯一" % (label, count))
out = text.replace(old, new, 1)
if new.strip() and new.strip()[:60] not in out:
raise SystemExit("✗ [%s] 替换后找不到新内容" % label)
print(" ✓ %s" % label)
return out
# ======================================================================
# 1. 新的分页定义
# ======================================================================
NEW_TABS = r''' /** 定义所有分页(2.3.0 精简版)。
*
* 用户要求「杂七杂八功能都删掉」,只留这些。所以这里**显式列按钮**,
* 不再用 groups 把电脑端所有功能无脑铺上来 —— 那正是"乱"的来源。
*
* 每页按钮数控制在 7 个以内,一屏放得下不用滚。
*/
private void initTabs() {
tabDefs = new TabDef[]{
new TabDef("屏幕", new Act[]{
act("屏幕镜像", new Runnable() {
public void run() { openMirrorPanel(); }
}),
act("返回桌面", new Runnable() {
public void run() {
sendCommand(Json.obj("cmd", "show_desktop"), "返回桌面");
}
}),
act("亮度 +", new Runnable() {
public void run() { changeBrightness(10); }
}),
act("亮度 -", new Runnable() {
public void run() { changeBrightness(-10); }
}),
act("截屏", new Runnable() {
public void run() { doScreenshot(); }
}),
}),
new TabDef("鼠标", new Act[]{
act("鼠标控制", new Runnable() {
public void run() { showMousePanel(); }
}),
}),
new TabDef("媒体", new Act[]{
act("播放/暂停", new Runnable() {
public void run() { media("playpause"); }
}),
act("上一曲", new Runnable() {
public void run() { media("prev"); }
}),
act("下一曲", new Runnable() {
public void run() { media("next"); }
}),
act("音量 +", new Runnable() {
public void run() { media("volup"); }
}),
act("音量 -", new Runnable() {
public void run() { media("voldown"); }
}),
act("静音", new Runnable() {
public void run() { media("mute"); }
}),
act("播放器", new Runnable() {
public void run() { showPlayerPanel(); }
}),
}),
new TabDef("文件", new Act[]{
act("发送文件", new Runnable() {
public void run() { pickFile(REQ_FILE, "*/*"); }
}),
act("打开回收站", new Runnable() {
public void run() {
runFeature("open_recycle", Json.obj(), "打开回收站");
}
}),
}),
new TabDef("电脑", new Act[]{
act("打开网址", new Runnable() {
public void run() { promptOpenUrl(); }
}),
act("快捷命令", new Runnable() {
public void run() { pickQuickApp(); }
}),
act("进程管理", new Runnable() {
public void run() { pickProcess(); }
}),
act("窗口置顶", new Runnable() {
public void run() {
runFeature("window_top", Json.obj(), "窗口置顶");
}
}),
act("结束所有进程", new Runnable() {
public void run() {
confirmKillAll();
}
}),
}),
new TabDef("语音", new Act[]{
act("语音播报", new Runnable() {
public void run() { promptSay(); }
}),
act("朗读剪贴板", new Runnable() {
public void run() {
sendCommand(Json.obj("cmd", "say_clip"), "朗读剪贴板");
}
}),
}),
new TabDef("电源", new Act[]{
act("锁屏", new Runnable() {
public void run() { confirmPower("lock", "锁屏"); }
}),
act("睡眠", new Runnable() {
public void run() { confirmPower("sleep", "睡眠"); }
}),
act("关机", new Runnable() {
public void run() { confirmPower("shutdown", "关机"); }
}),
act("重启", new Runnable() {
public void run() { confirmPower("restart", "重启"); }
}),
}),
};
tabBar.removeAllViews();
for (int i = 0; i < tabDefs.length; i++) {
final int index = i;
TextView tab = new TextView(this);
tab.setText(tabDefs[i].name);
tab.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
tab.setGravity(Gravity.CENTER);
tab.setPadding(dp(14), dp(8), dp(14), dp(8));
tab.setBackgroundColor(0x00000000);
tab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
selectTab(index);
}
});
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT);
params.setMargins(0, 0, 0, 0);
tab.setLayoutParams(params);
tabBar.addView(tab);
}
selectTab(0);
}
'''
# ======================================================================
# 2. 新的鼠标面板(中间滑动区 + 下面三个按钮)
# ======================================================================
NEW_MOUSE = r''' private void showMousePanel() {
if (mousePanel == null) {
buildMousePanel();
}
showOnly(mousePanel);
appendLog("鼠标:中间滑动移动光标,下面左键/滚轮/右键");
}
/** 远程鼠标控制面板(2.3.0 版)。
*
* 用户要求:「中间是一个鼠标,可以上下左右滑动控制,
* 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
*
* 布局:
*
* ┌──────────────────────────┐
* │ 返回(右上角) │
* │ │
* │ 滑动区(占满) │ <- 手指滑动 = 移动光标
* │ │
* ├────────┬────────┬────────┤
* │ 左键 │ 滚轮 │ 右键 │
* └────────┴────────┴────────┘
*
* 滚轮按钮:**点一下 = 中键单击**,**上下滑 = 滚动**。
* 这样三个按钮就把鼠标的左/中/右 + 滚轮全覆盖了。
*/
private void buildMousePanel() {
mousePanel = new FrameLayout(this);
mousePanel.setBackgroundColor(0xFF101418);
LinearLayout col = new LinearLayout(this);
col.setOrientation(LinearLayout.VERTICAL);
col.setPadding(dp(6), dp(34), dp(6), dp(6));
mousePanel.addView(col, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// ---- 滑动区 ----
mousePad = new View(this);
mousePad.setBackgroundColor(0xFF1B2028);
LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
padLp.setMargins(0, 0, 0, dp(6));
col.addView(mousePad, padLp);
// ---- 底部三个按钮 ----
LinearLayout keys = new LinearLayout(this);
keys.setOrientation(LinearLayout.HORIZONTAL);
col.addView(keys, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(46)));
keys.addView(mouseKey("左键", new Runnable() {
public void run() { sendMouse("click", "left", 0); }
}), keyLp());
// 滚轮:点=中键,上下滑=滚动
final Button wheel = new Button(this);
wheel.setText("滚轮");
wheel.setTextSize(12f);
wheel.setAllCaps(false);
wheel.setPadding(0, 0, 0, 0);
wheel.setMinWidth(0);
wheel.setMinimumWidth(0);
wheel.setLayoutParams(keyLp());
wheel.setOnTouchListener(new View.OnTouchListener() {
private float downY = 0f;
private int accumulated = 0;
private boolean pressed = false;
@Override
public boolean onTouch(View v, android.view.MotionEvent e) {
switch (e.getActionMasked()) {
case android.view.MotionEvent.ACTION_DOWN:
downY = e.getY();
accumulated = 0;
pressed = true;
v.setPressed(true);
return true;
case android.view.MotionEvent.ACTION_MOVE:
if (!pressed) {
return true;
}
// 每滑过一个"格子"滚一格。
// 手表屏幕小,一格取 18dp 比较好按。
float cell = dp(18);
float dy = e.getY() - downY;
accumulated = (int) (dy / cell);
// 每积累一格发一次,发完把基准挪过去
while (accumulated != 0) {
int dir = accumulated > 0 ? -3 : 3; // 往下滑=向下滚
sendMouse("scroll", "", dir);
downY += (accumulated > 0 ? cell : -cell);
accumulated = (int) ((e.getY() - downY) / cell);
}
return true;
case android.view.MotionEvent.ACTION_UP:
case android.view.MotionEvent.ACTION_CANCEL:
v.setPressed(false);
if (pressed) {
pressed = false;
// 没滑动就是一次中键单击
float moved = Math.abs(e.getY() - downY);
if (moved < dp(8)) {
sendMouse("click", "middle", 0);
}
}
return true;
default:
return true;
}
}
});
keys.addView(wheel);
keys.addView(mouseKey("右键", new Runnable() {
public void run() { sendMouse("click", "right", 0); }
}), keyLp());
// ---- 返回 ----
Button back = new Button(this);
back.setText("返回");
back.setTextSize(11f);
back.setAllCaps(false);
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
dp(58), dp(26));
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
backLp.setMargins(0, dp(4), dp(4), 0);
back.setLayoutParams(backLp);
back.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showControlPanel();
}
});
mousePanel.addView(back);
// ---- 触摸:滑动移动光标 ----
final android.view.GestureDetector detector =
new android.view.GestureDetector(this,
new android.view.GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll(android.view.MotionEvent e1,
android.view.MotionEvent e2,
float dx, float dy) {
// 手指往哪滑,光标往哪走:dx/dy 是"内容移动量",
// 也就是手指移动的反方向,所以取负。
sendMouseMove(-dx, -dy);
return true;
}
});
mousePad.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, android.view.MotionEvent e) {
detector.onTouchEvent(e);
return true;
}
});
}
private LinearLayout.LayoutParams keyLp() {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
lp.setMargins(dp(2), 0, dp(2), 0);
return lp;
}
'''
# ======================================================================
# 3. 播放器(面板 + 逻辑)
# ======================================================================
PLAYER_CODE = r'''
// ==================================================================
// 播放器(2.3.0 新增)
// ==================================================================
//
// 用户要求:「再加一个视频播放器和音乐播放器,有暂停开始,
// 进度左右划,下一个上一个」。
//
// 设计取舍:**让手表自己放**,而不是让电脑放。
//
// 如果让电脑放(调 play_video),电脑会打开它自己的播放器 ——
// 我们就**没法拿到进度、也没法精确 seek**,只能发媒体键。
// 那"进度左右划"就是假的。
//
// 手表自己放的话:MediaPlayer / VideoView 的
// getCurrentPosition / seekTo 都是现成的,
// 进度条、拖动、上下一首全部真实可控。
//
// 代价:要先从电脑把文件下到手表上(几百 KB 到几十 MB)。
// 文件走现有的分块传输(EVT_FILE),下完自动播。
private FrameLayout playerPanel;
private android.widget.VideoView playerVideo;
private android.media.MediaPlayer playerAudio;
private android.widget.SeekBar playerSeek;
private TextView playerTitle;
private TextView playerClock;
private TextView playerEmpty;
private Button playerPlayBtn;
private LinearLayout playerStage;
/** 电脑上的媒体文件清单(media_list 返回)。 */
private final java.util.List<java.util.Map<String, Object>> playerFiles =
new java.util.ArrayList<java.util.Map<String, Object>>();
private int playerIndex = -1;
private boolean playerIsVideo = false;
private boolean playerLoaded = false;
private boolean playerDragging = false;
/** 正在等播放器文件下载完 —— 下完 handleIncomingFile 会直接播。 */
private boolean playerPendingPlay = false;
private final android.os.Handler playerTick = new android.os.Handler();
private final Runnable playerTicker = new Runnable() {
@Override
public void run() {
if (playerPanel == null || playerPanel.getVisibility() != View.VISIBLE) {
return;
}
if (!playerDragging) {
try {
int pos = playerPosition();
int dur = playerDuration();
if (dur > 0) {
playerSeek.setMax(dur);
playerSeek.setProgress(pos);
playerClock.setText(fmtTime(pos) + " / " + fmtTime(dur));
}
} catch (Exception ignored) {
}
}
playerTick.postDelayed(this, 400);
}
};
private void buildPlayerPanel() {
playerPanel = new FrameLayout(this);
playerPanel.setBackgroundColor(0xFF0E1216);
LinearLayout col = new LinearLayout(this);
col.setOrientation(LinearLayout.VERTICAL);
col.setPadding(dp(6), dp(34), dp(6), dp(6));
playerPanel.addView(col, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// ---- 标题 ----
playerTitle = new TextView(this);
playerTitle.setText("播放器");
playerTitle.setTextColor(0xFFFFFFFF);
playerTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
playerTitle.setSingleLine(true);
playerTitle.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
playerTitle.setPadding(dp(4), 0, dp(4), dp(4));
col.addView(playerTitle, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
// ---- 画面 / 封面区 ----
playerStage = new LinearLayout(this);
playerStage.setOrientation(LinearLayout.VERTICAL);
playerStage.setGravity(Gravity.CENTER);
playerStage.setBackgroundColor(0xFF10151A);
LinearLayout.LayoutParams stageLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
stageLp.setMargins(0, 0, 0, dp(4));
col.addView(playerStage, stageLp);
playerEmpty = new TextView(this);
playerEmpty.setText("点「列表」选歌/视频\n(从电脑的音乐、视频目录读)");
playerEmpty.setTextColor(0xFF8B96A3);
playerEmpty.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
playerEmpty.setGravity(Gravity.CENTER);
playerStage.addView(playerEmpty, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// ---- 进度条 ----
playerSeek = new android.widget.SeekBar(this);
playerSeek.setMax(1000);
playerSeek.setProgress(0);
col.addView(playerSeek, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(28)));
playerSeek.setOnSeekBarChangeListener(
new android.widget.SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(android.widget.SeekBar bar,
int progress, boolean fromUser) {
if (fromUser) {
playerClock.setText(fmtTime(progress) + " / "
+ fmtTime(playerDuration()));
}
}
@Override
public void onStartTrackingTouch(android.widget.SeekBar bar) {
playerDragging = true;
}
@Override
public void onStopTrackingTouch(android.widget.SeekBar bar) {
playerDragging = false;
seekTo(bar.getProgress());
}
});
// ---- 时间 ----
playerClock = new TextView(this);
playerClock.setText("00:00 / 00:00");
playerClock.setTextColor(0xFF9AA4B2);
playerClock.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10);
playerClock.setGravity(Gravity.CENTER);
col.addView(playerClock, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
// ---- 上一首 / 播放 / 下一首 ----
LinearLayout ctl = new LinearLayout(this);
ctl.setOrientation(LinearLayout.HORIZONTAL);
ctl.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams ctlLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52));
ctlLp.setMargins(0, dp(4), 0, 0);
col.addView(ctl, ctlLp);
ctl.addView(playerBtn("上一", new Runnable() {
public void run() { stepPlayer(-1); }
}), new LinearLayout.LayoutParams(0, dp(50), 1f));
playerPlayBtn = playerBtn("播放", new Runnable() {
public void run() { togglePlayer(); }
});
ctl.addView(playerPlayBtn, new LinearLayout.LayoutParams(0, dp(50), 1.4f));
ctl.addView(playerBtn("下一", new Runnable() {
public void run() { stepPlayer(1); }
}), new LinearLayout.LayoutParams(0, dp(50), 1f));
ctl.addView(playerBtn("列表", new Runnable() {
public void run() { pickPlayerFile(); }
}), new LinearLayout.LayoutParams(0, dp(50), 1f));
// ---- 返回 ----
Button back = new Button(this);
back.setText("返回");
back.setTextSize(11f);
back.setAllCaps(false);
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
dp(58), dp(26));
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
backLp.setMargins(0, dp(4), dp(4), 0);
back.setLayoutParams(backLp);
back.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showControlPanel();
}
});
playerPanel.addView(back);
// ---- 画面区左右划 = 快进/快退 10 秒 ----
final android.view.GestureDetector swipe =
new android.view.GestureDetector(this,
new android.view.GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onFling(android.view.MotionEvent e1,
android.view.MotionEvent e2,
float vx, float vy) {
if (e1 == null || e2 == null) {
return false;
}
float dx = e2.getX() - e1.getX();
if (Math.abs(dx) < dp(30) || Math.abs(dx) < Math.abs(vy)) {
return false;
}
// 右滑 = 前进 10 秒,左滑 = 后退 10 秒
seekBy(dx > 0 ? 10000 : -10000);
return true;
}
@Override
public boolean onSingleTapConfirmed(android.view.MotionEvent e) {
togglePlayer(); // 点画面 = 播放/暂停
return true;
}
});
playerStage.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, android.view.MotionEvent e) {
swipe.onTouchEvent(e);
return true;
}
});
}
private Button playerBtn(String text, final Runnable action) {
Button b = new Button(this);
b.setText(text);
b.setTextSize(12f);
b.setAllCaps(false);
b.setPadding(0, 0, 0, 0);
b.setMinWidth(0);
b.setMinimumWidth(0);
b.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
action.run();
}
});
return b;
}
private void showPlayerPanel() {
if (playerPanel == null) {
buildPlayerPanel();
}
showOnly(playerPanel);
startPlayerTicker();
if (!playerLoaded) {
loadPlayerList();
}
}
private void startPlayerTicker() {
playerTick.removeCallbacks(playerTicker);
playerTick.post(playerTicker);
}
private void stopPlayerTicker() {
playerTick.removeCallbacks(playerTicker);
}
/** 拉电脑上的媒体清单。 */
private void loadPlayerList() {
playerLoaded = true;
playerTitle.setText("读取电脑媒体…");
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> reply =
client.call(Json.obj("cmd", "media_list"), 25000);
final List<Object> files = Json.list(reply, "files");
ui.post(new Runnable() {
@Override
public void run() {
playerFiles.clear();
for (Object o : files) {
Map<String, Object> m = Json.asMap(o);
if (m != null) {
playerFiles.add(m);
}
}
if (playerFiles.isEmpty()) {
playerTitle.setText("播放器");
playerEmpty.setText("电脑的音乐/视频目录里没有文件\n"
+ "把文件放进「音乐」「视频」或「下载」文件夹");
playerEmpty.setVisibility(View.VISIBLE);
} else {
playerTitle.setText("播放器 · 共 " + playerFiles.size() + " 个");
pickPlayerFile();
}
}
});
} catch (final IOException e) {
ui.post(new Runnable() {
@Override
public void run() {
playerLoaded = false;
playerTitle.setText("播放器");
playerEmpty.setText("读不到电脑媒体列表:" + e.getMessage());
playerEmpty.setVisibility(View.VISIBLE);
}
});
}
}
});
}
private void pickPlayerFile() {
if (playerFiles.isEmpty()) {
loadPlayerList();
return;
}
String[] labels = new String[playerFiles.size()];
for (int i = 0; i < playerFiles.size(); i++) {
Map<String, Object> f = playerFiles.get(i);
String kind = Json.str(f, "kind", "audio");
labels[i] = ("video".equals(kind) ? "▶ " : "♪ ")
+ Json.str(f, "name", "?")
+ "\n" + humanSize(Json.integer(f, "size", 0));
}
new AlertDialog.Builder(MainActivity.this)
.setTitle("电脑上的媒体")
.setItems(labels, new android.content.DialogInterface.OnClickListener() {
@Override
public void onClick(android.content.DialogInterface dialog,
int which) {
openPlayerItem(which);
}
})
.setNegativeButton("关闭", null)
.show();
}
/** 下载并播放第 index 个文件。 */
private void openPlayerItem(final int index) {
if (index < 0 || index >= playerFiles.size()) {
return;
}
playerIndex = index;
final Map<String, Object> f = playerFiles.get(index);
final String name = Json.str(f, "name", "media");
final String path = Json.str(f, "path", "");
playerIsVideo = "video".equals(Json.str(f, "kind", "audio"));
// 先看手表上是不是已经有了(可能刚播过)
File cached = mediaCacheFile(name);
if (cached != null && cached.isFile() && cached.length() > 0) {
startPlayback(cached, name);
return;
}
playerTitle.setText("下载 " + name + " …");
playerEmpty.setText("正在从电脑下载…");
playerEmpty.setVisibility(View.VISIBLE);
playerPendingPlay = true;
incoming = new ByteArrayOutputStream();
incomingName = name;
io.execute(new Runnable() {
@Override
public void run() {
try {
client.call(Json.obj("cmd", "media_pull", "path", path),
600000);
} catch (final IOException e) {
playerPendingPlay = false;
ui.post(new Runnable() {
@Override
public void run() {
playerTitle.setText("播放器");
playerEmpty.setText("下载失败:" + e.getMessage());
playerEmpty.setVisibility(View.VISIBLE);
}
});
}
}
});
}
private File mediaCacheFile(String name) {
File dir = getExternalFilesDir(null);
if (dir == null) {
dir = getFilesDir();
}
File media = new File(dir, "media");
if (!media.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
media.mkdirs();
}
// 文件名里可能有路径分隔符之类,清一下
String safe = name.replaceAll("[\\\\/:*?\"<>|]", "_");
return new File(media, safe);
}
/** 真正开始播放本地文件。 */
private void startPlayback(final File file, final String name) {
stopPlayback();
playerTitle.setText(name);
playerEmpty.setVisibility(View.GONE);
playerStage.removeAllViews();
if (playerIsVideo) {
playerVideo = new android.widget.VideoView(this);
playerVideo.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
playerVideo.setVideoPath(file.getAbsolutePath());
playerVideo.setOnPreparedListener(new android.media.MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(android.media.MediaPlayer mp) {
playerVideo.start();
playerPlayBtn.setText("暂停");
try {
playerSeek.setMax(mp.getDuration());
} catch (Exception ignored) {
}
}
});
playerVideo.setOnCompletionListener(new android.media.MediaPlayer.OnCompletionListener() {
@Override
public void onCompletion(android.media.MediaPlayer mp) {
// 播完自动下一首(用户要的"下一个")
stepPlayer(1);
}
});
playerStage.addView(playerVideo);
playerVideo.requestFocus();
} else {
try {
playerAudio = new android.media.MediaPlayer();
playerAudio.setDataSource(file.getAbsolutePath());
playerAudio.prepare();
playerAudio.start();
playerPlayBtn.setText("暂停");
playerSeek.setMax(playerAudio.getDuration());
playerAudio.setOnCompletionListener(
new android.media.MediaPlayer.OnCompletionListener() {
@Override
public void onCompletion(android.media.MediaPlayer mp) {
stepPlayer(1);
}
});
// 音频没有画面,摆个大音符
TextView art = new TextView(this);
art.setText("♪\n" + name);
art.setTextColor(0xFFFFFFFF);
art.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
art.setGravity(Gravity.CENTER);
playerStage.addView(art, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
} catch (Exception e) {
playerTitle.setText(name);
playerEmpty.setText("播放失败:" + e.getMessage());
playerEmpty.setVisibility(View.VISIBLE);
}
}
startPlayerTicker();
}
private void togglePlayer() {
try {
if (playerIsVideo && playerVideo != null) {
if (playerVideo.isPlaying()) {
playerVideo.pause();
playerPlayBtn.setText("播放");
} else {
playerVideo.start();
playerPlayBtn.setText("暂停");
}
} else if (playerAudio != null) {
if (playerAudio.isPlaying()) {
playerAudio.pause();
playerPlayBtn.setText("播放");
} else {
playerAudio.start();
playerPlayBtn.setText("暂停");
}
}
} catch (Exception ignored) {
}
}
private void stepPlayer(int delta) {
if (playerFiles.isEmpty()) {
pickPlayerFile();
return;
}
int next = playerIndex + delta;
if (next < 0) {
next = playerFiles.size() - 1;
}
if (next >= playerFiles.size()) {
next = 0;
}
openPlayerItem(next);
}
private int playerPosition() {
try {
if (playerIsVideo && playerVideo != null) {
return playerVideo.getCurrentPosition();
}
if (playerAudio != null) {
return playerAudio.getCurrentPosition();
}
} catch (Exception ignored) {
}
return 0;
}
private int playerDuration() {
try {
if (playerIsVideo && playerVideo != null) {
return playerVideo.getDuration();
}
if (playerAudio != null) {
return playerAudio.getDuration();
}
} catch (Exception ignored) {
}
return 0;
}
private void seekTo(int ms) {
try {
if (playerIsVideo && playerVideo != null) {
playerVideo.seekTo(ms);
} else if (playerAudio != null) {
playerAudio.seekTo(ms);
}
} catch (Exception ignored) {
}
}
private void seekBy(int deltaMs) {
int dur = playerDuration();
int target = playerPosition() + deltaMs;
if (target < 0) {
target = 0;
}
if (dur > 0 && target > dur) {
target = dur;
}
seekTo(target);
toast((deltaMs > 0 ? "快进 " : "快退 ")
+ Math.abs(deltaMs) / 1000 + " 秒");
}
private void stopPlayback() {
try {
if (playerVideo != null) {
playerVideo.stopPlayback();
playerVideo = null;
}
} catch (Exception ignored) {
}
try {
if (playerAudio != null) {
playerAudio.release();
playerAudio = null;
}
} catch (Exception ignored) {
}
playerStage.removeAllViews();
playerEmpty.setVisibility(View.VISIBLE);
playerPlayBtn.setText("播放");
}
private void releasePlayer() {
stopPlayerTicker();
stopPlayback();
}
private static String fmtTime(int ms) {
if (ms < 0) {
ms = 0;
}
int total = ms / 1000;
int m = total / 60;
int s = total % 60;
return String.format("%02d:%02d", m, s);
}
// ---- 亮度 / 结束所有进程(2.3.0 新增的显式入口)----
/** 调屏幕亮度。电脑端 brightness 是 10~100 的绝对值,
* 所以这里先问电脑当前值,再 ±delta。 */
private void changeBrightness(final int delta) {
io.execute(new Runnable() {
@Override
public void run() {
try {
Map<String, Object> reply =
client.call(Json.obj("cmd", "feature_run",
"name", "brightness",
"params", Json.obj()), 15000);
int now = Json.integer(reply, "value", 0);
if (now <= 0) {
now = 70;
}
int target = Math.max(10, Math.min(100, now + delta));
client.call(Json.obj("cmd", "feature_run",
"name", "brightness",
"params", Json.obj("value", target)), 15000);
final int shown = target;
ui.post(new Runnable() {
@Override
public void run() {
toast("亮度 " + shown + "%");
}
});
} catch (final IOException e) {
postError("亮度", e);
}
}
});
}
private void confirmKillAll() {
new AlertDialog.Builder(MainActivity.this)
.setTitle("结束所有进程")
.setMessage("会结束这台电脑上所有**用户程序**"
+ "(系统进程和本程序会保留)。继续?")
.setPositiveButton("结束", new android.content.DialogInterface.OnClickListener() {
@Override
public void onClick(android.content.DialogInterface dialog, int which) {
runFeature("kill_all", Json.obj(), "结束所有进程");
}
})
.setNegativeButton("取消", null)
.show();
}
'''
# ======================================================================
# 入口
# ======================================================================
def main() -> int:
if not JAVA.is_file():
print("✗ 找不到 %s" % JAVA)
return 1
text = JAVA.read_text(encoding="utf-8")
original_len = len(text)
print("=" * 62)
print(" 安卓端 2.3.0 改动")
print("=" * 62)
print(" 原文 %d 字符" % original_len)
print()
# ---- 1. initTabs 整体替换 ----
# 锚点:从 " /** 定义所有分页" 到 initTabs 的收尾 " selectTab(0);\n }"
start = text.find(" /** 定义所有分页")
if start < 0:
print("✗ 找不到 initTabs 的注释锚点")
return 1
end_mark = " selectTab(0);\n }\n"
end = text.find(end_mark, start)
if end < 0:
print("✗ 找不到 initTabs 的结束锚点")
return 1
end += len(end_mark)
old_tabs = text[start:end]
if "new TabDef(\"媒体\"" not in old_tabs or "new TabDef(\"电源\"" in old_tabs:
print("✗ initTabs 锚点范围不对(%d 字符)" % len(old_tabs))
return 1
text = text[:start] + NEW_TABS + text[end:]
print(" ✓ 替换 initTabs(%d -> %d 字符)" % (len(old_tabs), len(NEW_TABS)))
# ---- 2. 鼠标面板整体替换 ----
m_start = text.find(" private void showMousePanel() {")
if m_start < 0:
print("✗ 找不到 showMousePanel")
return 1
# 结束:sendMouseMove 方法之后、选择页注释之前
m_end_mark = " // ==================================================================\n // 连接后的选择页(远控 / 静默更新)"
m_end = text.find(m_end_mark, m_start)
if m_end < 0:
print("✗ 找不到鼠标面板结束锚点")
return 1
old_mouse = text[m_start:m_end]
if "sendMouseMove" not in old_mouse:
print("✗ 鼠标面板锚点范围不对")
return 1
# 保留 sendMouse 和 sendMouseMove(它们被别处调用)
keep_start = old_mouse.find(" private void sendMouse(String action")
if keep_start < 0:
print("✗ 找不到 sendMouse")
return 1
keep = old_mouse[keep_start:]
text = text[:m_start] + NEW_MOUSE + "\n" + keep + text[m_end:]
print(" ✓ 替换鼠标面板(%d -> %d 字符)" % (len(old_mouse), len(NEW_MOUSE) + len(keep)))
# ---- 3. 插入播放器代码 ----
# 放在鼠标面板之后、选择页之前
anchor = " // ==================================================================\n // 连接后的选择页(远控 / 静默更新)"
if text.count(anchor) != 1:
print("✗ 播放器插入锚点不唯一")
return 1
text = text.replace(anchor, PLAYER_CODE + "\n" + anchor, 1)
print(" ✓ 插入播放器代码(%d 字符)" % len(PLAYER_CODE))
# ---- 4. showOnly 登记新面板 ----
text = must_replace(
text,
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n"
" settingsPanel, mousePanel, choicePanel};",
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n"
" settingsPanel, mousePanel, choicePanel, playerPanel};",
"showOnly 登记播放器面板")
# ---- 5. 面板注册:addView + topMargin ----
text = must_replace(
text,
" applyPanelTopMargin(mousePanel);\n"
" applyPanelTopMargin(choicePanel);",
" applyPanelTopMargin(mousePanel);\n"
" applyPanelTopMargin(choicePanel);\n"
" applyPanelTopMargin(playerPanel);",
"播放器面板让出快捷栏高度")
# ---- 6. 建面板 + 挂到 root ----
text = must_replace(
text,
" buildChoicePanel(); // 同上:连上之后才显示\n"
" buildQuickBar();",
" buildChoicePanel(); // 同上:连上之后才显示\n"
" buildPlayerPanel(); // 播放器,先建不显示\n"
" buildQuickBar();",
"构建播放器面板")
# 挂到 root:在 choicePanel 的 addView 之后
choice_add = " root.addView(choicePanel, new FrameLayout.LayoutParams(\n" \
" ViewGroup.LayoutParams.MATCH_PARENT,\n" \
" ViewGroup.LayoutParams.MATCH_PARENT));\n"
if text.count(choice_add) != 1:
print("✗ choicePanel addView 锚点不唯一")
return 1
text = text.replace(
choice_add,
choice_add + " root.addView(playerPanel, new FrameLayout.LayoutParams(\n"
" ViewGroup.LayoutParams.MATCH_PARENT,\n"
" ViewGroup.LayoutParams.MATCH_PARENT));\n"
" playerPanel.setVisibility(View.GONE);\n",
1)
print(" ✓ 播放器面板挂到 root")
# ---- 7. handleIncomingFile 支持播放器 ----
text = must_replace(
text,
" appendLog(\"已保存 \" + name + \" (\" + humanSize(blob.length) + \")\");\n"
" toast(\"已保存到 \" + saved.getName());",
" appendLog(\"已保存 \" + name + \" (\" + humanSize(blob.length) + \")\");\n"
" if (playerPendingPlay) {\n"
" // 播放器在等这个文件 —— 下完直接播\n"
" playerPendingPlay = false;\n"
" File media = mediaCacheFile(name);\n"
" if (media != null) {\n"
" try {\n"
" java.io.FileOutputStream out =\n"
" new java.io.FileOutputStream(media);\n"
" out.write(blob);\n"
" out.close();\n"
" } catch (IOException ignored) {\n"
" }\n"
" startPlayback(media, name);\n"
" }\n"
" } else {\n"
" toast(\"已保存到 \" + saved.getName());\n"
" }",
"handleIncomingFile 播放器接管")
JAVA.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 改后 %d 字符(+%d)" % (len(text), len(text) - original_len))
return 0
if __name__ == "__main__":
sys.exit(main())