Compare commits
4
Commits
7b6593bb75
...
eafa112959
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafa112959 | ||
|
|
3631a037d8 | ||
|
|
cd8344c698 | ||
|
|
a16ad8595e |
@@ -318,6 +318,8 @@ ge` 的构造必须留在事件循环。这条约束决定了整个客户端的
|
|||||||
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||||
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
||||||
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
||||||
|
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
|
||||||
|
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
|
||||||
|
|
||||||
独立核对线路格式:
|
独立核对线路格式:
|
||||||
|
|
||||||
|
|||||||
+156
-5
@@ -125,13 +125,33 @@ std::rc::Rc::new(slint::VecModel::from(v)).into()
|
|||||||
|
|
||||||
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
|
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
|
||||||
|
|
||||||
### 槽位不会左右跳动
|
### 槽位一旦占定就不换人
|
||||||
|
|
||||||
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
|
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
|
||||||
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
|
|
||||||
"两路串了"。
|
|
||||||
|
|
||||||
`view.sender_id != wanted` 才重建 `View`(清零帧率窗口),避免每 33ms 重置一次统计。
|
原来那版是:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
||||||
|
let wanted = ids.get(i).copied();
|
||||||
|
```
|
||||||
|
|
||||||
|
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
|
||||||
|
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
|
||||||
|
|
||||||
|
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
|
||||||
|
顺序取第一个。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
||||||
|
if still_here { continue; }
|
||||||
|
```
|
||||||
|
|
||||||
|
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
|
||||||
|
看起来像卡住。
|
||||||
|
|
||||||
|
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
|
||||||
|
永远算不出来。
|
||||||
|
|
||||||
## 帧率:按计数除以墙钟
|
## 帧率:按计数除以墙钟
|
||||||
|
|
||||||
@@ -160,6 +180,137 @@ let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
|||||||
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
|
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
|
||||||
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
||||||
|
|
||||||
|
## 防作弊:release 队伍端只接收绑定的那一块
|
||||||
|
|
||||||
|
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
|
||||||
|
的板子。
|
||||||
|
|
||||||
|
**关键是这件事必须做在接收端,而不是界面里。**
|
||||||
|
|
||||||
|
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
|
||||||
|
|
||||||
|
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
|
||||||
|
界面藏不藏毫无关系。
|
||||||
|
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
|
||||||
|
|
||||||
|
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
|
||||||
|
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
|
||||||
|
常量),数据也不进入任何管线。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||||
|
config.camera_id.filter(|_| args.camera_id.is_none())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
四种情况不限制,各有理由:
|
||||||
|
|
||||||
|
| 情况 | 为什么不限制 |
|
||||||
|
|---|---|
|
||||||
|
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
|
||||||
|
| debug 构建 | 开发时需要看到全场 |
|
||||||
|
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
|
||||||
|
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
|
||||||
|
|
||||||
|
### 遥测按编译模式门控
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||||
|
```
|
||||||
|
|
||||||
|
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
|
||||||
|
也不显示),stdout 的诊断日志同样门控。
|
||||||
|
|
||||||
|
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
|
||||||
|
就泄露了。
|
||||||
|
|
||||||
|
裁判端不受影响——双方遥测对判罚有用。
|
||||||
|
|
||||||
|
### `--list` 在 release 队伍端直接拒绝
|
||||||
|
|
||||||
|
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
|
||||||
|
不给:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 选择界面保留
|
||||||
|
|
||||||
|
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
|
||||||
|
(对手需要的是实时画面,不是 id 字符串)。
|
||||||
|
|
||||||
|
### 这挡不住什么
|
||||||
|
|
||||||
|
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
|
||||||
|
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
|
||||||
|
|
||||||
|
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
|
||||||
|
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
|
||||||
|
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
|
||||||
|
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
|
||||||
|
|
||||||
|
## 缩放与自适应布局
|
||||||
|
|
||||||
|
### 滚轮缩放 + 拖动平移
|
||||||
|
|
||||||
|
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
|
||||||
|
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
|
||||||
|
|
||||||
|
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
|
||||||
|
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
|
||||||
|
- 双击复位
|
||||||
|
- 缩回 1.0 时自动归位
|
||||||
|
|
||||||
|
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
|
||||||
|
"跳了一下"。
|
||||||
|
|
||||||
|
`image-fit` 必须跟着 `zoom` 切:
|
||||||
|
|
||||||
|
```
|
||||||
|
image-fit: root.zoom <= 1.0 ? contain : fill;
|
||||||
|
```
|
||||||
|
|
||||||
|
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
|
||||||
|
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
|
||||||
|
变大了,只是画面没跟上。
|
||||||
|
|
||||||
|
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
|
||||||
|
|
||||||
|
### 为什么两格必须等宽
|
||||||
|
|
||||||
|
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
|
||||||
|
|
||||||
|
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
|
||||||
|
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
|
||||||
|
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
|
||||||
|
|
||||||
|
三处一起改:
|
||||||
|
|
||||||
|
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
|
||||||
|
决定画面大小。
|
||||||
|
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
|
||||||
|
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
|
||||||
|
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
|
||||||
|
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
|
||||||
|
|
||||||
|
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
|
||||||
|
|
||||||
|
### 启动即最大化
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ui.window().set_maximized(true);
|
||||||
|
ui.show()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
|
||||||
|
状态。
|
||||||
|
|
||||||
|
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
|
||||||
|
捷键。裁判终端偶尔要切到别的窗口核对数据。
|
||||||
|
|
||||||
## 内存:解码只留最新一张
|
## 内存:解码只留最新一张
|
||||||
|
|
||||||
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
|
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
|
||||||
|
|||||||
+147
-18
@@ -56,6 +56,15 @@ const USAGE: &str = "\
|
|||||||
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
|
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
|
||||||
const UI_POLL: Duration = Duration::from_millis(33);
|
const UI_POLL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
|
/// 队伍端在 release 下是否显示技术诊断信息。
|
||||||
|
///
|
||||||
|
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
|
||||||
|
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
|
||||||
|
/// 那套东西,忘开一次就泄露了。
|
||||||
|
///
|
||||||
|
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
|
||||||
|
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||||
|
|
||||||
/// 帧率统计窗口。
|
/// 帧率统计窗口。
|
||||||
///
|
///
|
||||||
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
||||||
@@ -136,14 +145,41 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
config_path.display()
|
config_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 队伍端在 release 下已经绑定时,把接收端限制到那一块。
|
||||||
|
//
|
||||||
|
// 这是本次改动里唯一有实际防护的一处:`.slint` 里不显示 id 只是藏起文字,而
|
||||||
|
// 广播的 Hello 早就把 MAC 派生的 id 和 IP 送出去了(对手 tcpdump 一下就有)。
|
||||||
|
// 在**接收端**拒绝才是真的:不重组、不解码、不进 discovery。
|
||||||
|
//
|
||||||
|
// 四种情况不限制:
|
||||||
|
// - 裁判端(中立第三方,本来就要两块板子)
|
||||||
|
// - debug 构建(开发时要能看到全场)
|
||||||
|
// - 队伍端还没绑定(选择界面需要列出候选)
|
||||||
|
// - 命令行临时用别的板子跑(--camera-id 覆盖配置)
|
||||||
|
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||||
|
config.camera_id.filter(|_| args.camera_id.is_none())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let rx = Arc::new(Receiver::start(
|
let rx = Arc::new(Receiver::start(
|
||||||
args.camera,
|
args.camera,
|
||||||
config.role.to_protocol(),
|
config.role.to_protocol(),
|
||||||
|
restrict,
|
||||||
)?);
|
)?);
|
||||||
let discovery = rx.discovery();
|
let discovery = rx.discovery();
|
||||||
let frames = rx.frames();
|
let frames = rx.frames();
|
||||||
|
|
||||||
if args.list {
|
if args.list {
|
||||||
|
// 队伍端 release 下 `--list` 会列出场上所有板子(包括对手的 id 和 IP),
|
||||||
|
// 正是要防的那件事。所以这里直接拒绝,而不是列出来再让人别看。
|
||||||
|
if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||||
|
return Err(
|
||||||
|
"--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。\
|
||||||
|
release 的队伍端请直接用已绑定的配置运行。"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
// 等几个广播周期,让列表有内容。
|
// 等几个广播周期,让列表有内容。
|
||||||
let socket = bind_socket()?;
|
let socket = bind_socket()?;
|
||||||
let for_list = Arc::clone(&rx);
|
let for_list = Arc::clone(&rx);
|
||||||
@@ -236,8 +272,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
match config.role {
|
match config.role {
|
||||||
Role::Team => {
|
Role::Team => {
|
||||||
let ui = TeamApp::new()?;
|
let ui = TeamApp::new()?;
|
||||||
|
|
||||||
|
// 启动即最大化。必须在 show() 之前:之后调用会被后端当成"用户点了最大化
|
||||||
|
// 按钮"。取 set_maximized 而不是 set_fullscreen,因为全屏会盖掉菜单栏和
|
||||||
|
// Dock,裁判终端要切去别的窗口核对数据。
|
||||||
|
ui.window().set_maximized(true);
|
||||||
ui.show()?;
|
ui.show()?;
|
||||||
|
|
||||||
|
// 接收端的限制在构造 Receiver 时已经按配置设好了(见上面 `restrict`),
|
||||||
|
// 这里不重复设:只有选择界面上新选定的那一次需要改它。
|
||||||
|
let rx_for_pick = Arc::clone(&rx);
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
let weak = ui.as_weak();
|
||||||
let discovery = Arc::clone(&discovery);
|
let discovery = Arc::clone(&discovery);
|
||||||
let decoded = Arc::clone(&decoded);
|
let decoded = Arc::clone(&decoded);
|
||||||
@@ -288,6 +333,13 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
log::info!("bound to camera 0x{id:08X}; saved in the config");
|
log::info!("bound to camera 0x{id:08X}; saved in the config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选定之后把接收端也限制到这一块。此后场上别人的板子即使把流推到
|
||||||
|
// 本机端口,也不会被重组或解码。
|
||||||
|
//
|
||||||
|
// 解除限制(传 None)要等到重启:配置里有了 camera_id 就会走绑定
|
||||||
|
// 分支,限制随之恢复,两者不会不一致。
|
||||||
|
rx_for_pick.restrict_to(Some(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
let choices_for_poll = Arc::clone(&choices);
|
let choices_for_poll = Arc::clone(&choices);
|
||||||
@@ -331,12 +383,20 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let stats = discovery.stats(bound);
|
let stats = discovery.stats(bound);
|
||||||
view.update_fps(&stats);
|
view.update_fps(&stats);
|
||||||
|
|
||||||
if !view.reported && stats.frames_shown > 0 {
|
// 遥测日志同样按编译模式门控:release 的队伍端不往 stdout 打帧率/丢帧这些
|
||||||
|
// 能被对手读到的数字。
|
||||||
|
if TEAM_DIAGNOSTICS && !view.reported && stats.frames_shown > 0 {
|
||||||
log_diagnostics(&stats, bound);
|
log_diagnostics(&stats, bound);
|
||||||
view.reported = true;
|
view.reported = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.set_stats(render_camera_stats(&cameras, bound, &stats, view.fps));
|
ui.set_stats(render_camera_stats(
|
||||||
|
&cameras,
|
||||||
|
bound,
|
||||||
|
&stats,
|
||||||
|
view.fps,
|
||||||
|
TEAM_DIAGNOSTICS,
|
||||||
|
));
|
||||||
ui.set_health(render_health(team, armor.as_ref()));
|
ui.set_health(render_health(team, armor.as_ref()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,6 +409,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
Role::Referee => {
|
Role::Referee => {
|
||||||
let ui = RefereeApp::new()?;
|
let ui = RefereeApp::new()?;
|
||||||
|
ui.window().set_maximized(true);
|
||||||
ui.show()?;
|
ui.show()?;
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
let weak = ui.as_weak();
|
||||||
@@ -365,15 +426,47 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
let cameras = discovery.online_cameras();
|
||||||
|
|
||||||
// 两路按 id 顺序填槽。槽位保持稳定(不会因为某块板子临时离线就重新
|
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
|
||||||
// 洗牌),否则画面会左右跳动。
|
//
|
||||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
// 两条规则:
|
||||||
|
//
|
||||||
|
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
|
||||||
|
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
|
||||||
|
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
|
||||||
|
// 两边一模一样,会以为两队的图传串了。
|
||||||
|
//
|
||||||
|
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
|
||||||
|
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
|
||||||
|
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
|
||||||
|
//
|
||||||
|
// 所以每轮最多消费一个候选 id。
|
||||||
|
let mut next_candidate = cameras.iter().map(|c| c.id);
|
||||||
|
|
||||||
for (i, view) in views.iter_mut().enumerate() {
|
for view in views.iter_mut() {
|
||||||
let wanted = ids.get(i).copied();
|
let still_here = view
|
||||||
if view.sender_id != wanted {
|
.sender_id
|
||||||
|
.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
||||||
|
|
||||||
|
if still_here {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 槽位已占且板子还在场上 → 保留(上面已 continue)。到这里说明
|
||||||
|
// 槽位是空的,或板子掉线了。
|
||||||
|
let candidate = next_candidate
|
||||||
|
.next()
|
||||||
|
// 别把刚掉线的这块又立刻选回同一个槽位:它短暂离线又回来时,
|
||||||
|
// 画面会原地卡一下才恢复。
|
||||||
|
.filter(|id| Some(*id) != view.sender_id);
|
||||||
|
|
||||||
|
match candidate {
|
||||||
|
Some(id) => {
|
||||||
*view = View::new();
|
*view = View::new();
|
||||||
view.sender_id = wanted;
|
view.sender_id = Some(id);
|
||||||
|
}
|
||||||
|
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
|
||||||
|
// 变回空槽),这样它回来时还在原位。
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +491,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
view.reported = true;
|
view.reported = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps);
|
// 裁判端始终显示诊断信息:它是中立方,双方遥测对判罚有用。
|
||||||
|
let view_stats =
|
||||||
|
render_camera_stats(&cameras, sender_id, &stats, view.fps, true);
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
ui.set_stats_a(view_stats);
|
ui.set_stats_a(view_stats);
|
||||||
@@ -551,11 +646,15 @@ fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 把某一路的统计渲染成界面结构。
|
/// 把某一路的统计渲染成界面结构。
|
||||||
|
///
|
||||||
|
/// `diagnostics` 为 false 时不产出技术字段——它们留空字符串,界面也不显示。留空而
|
||||||
|
/// 不是填 0 或假值:0 会被读成"丢帧 0"这种看起来像真值的东西。
|
||||||
fn render_camera_stats(
|
fn render_camera_stats(
|
||||||
cameras: &[CameraInfo],
|
cameras: &[CameraInfo],
|
||||||
sender_id: u32,
|
sender_id: u32,
|
||||||
stats: &SourceStats,
|
stats: &SourceStats,
|
||||||
fps: f32,
|
fps: f32,
|
||||||
|
diagnostics: bool,
|
||||||
) -> CameraStats {
|
) -> CameraStats {
|
||||||
let online = cameras
|
let online = cameras
|
||||||
.iter()
|
.iter()
|
||||||
@@ -565,6 +664,10 @@ fn render_camera_stats(
|
|||||||
|
|
||||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||||
// 比让人以为板子挂了诚实。
|
// 比让人以为板子挂了诚实。
|
||||||
|
//
|
||||||
|
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
|
||||||
|
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
|
||||||
|
// 见 Receiver::restrict_to)。
|
||||||
let status = if stats.frames_shown == 0 {
|
let status = if stats.frames_shown == 0 {
|
||||||
if online {
|
if online {
|
||||||
"已连接,等待画面…".to_string()
|
"已连接,等待画面…".to_string()
|
||||||
@@ -578,17 +681,43 @@ fn render_camera_stats(
|
|||||||
CameraStats {
|
CameraStats {
|
||||||
online,
|
online,
|
||||||
status_text: status.into(),
|
status_text: status.into(),
|
||||||
fps: format!("{fps:.1}").into(),
|
// 同理:没出过帧时 fps 是 0.0,读起来像"板子卡死了",而真相是"还没收到"。
|
||||||
resolution: format!("{}x{}", stats.width, stats.height).into(),
|
// 状态行已经写了"已连接,等待画面…",这里不该再补一个矛盾的数字。
|
||||||
frame_bytes: format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0).into(),
|
fps: (diagnostics && stats.frames_shown > 0)
|
||||||
frames_shown: stats.frames_shown.to_string().into(),
|
.then(|| format!("{fps:.1}"))
|
||||||
frames_dropped: stats.frames_dropped.to_string().into(),
|
.unwrap_or_default()
|
||||||
loss_percent: format!("{:.1}%", stats.loss_percent()).into(),
|
.into(),
|
||||||
datagrams: stats.datagrams.to_string().into(),
|
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是显示成
|
||||||
|
// "0x0"——一个看起来像分辨率的假分辨率。用 frames_shown 把它挡掉。
|
||||||
|
resolution: (diagnostics && stats.frames_shown > 0)
|
||||||
|
.then(|| format!("{}x{}", stats.width, stats.height))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
frame_bytes: (diagnostics && stats.frames_shown > 0)
|
||||||
|
.then(|| format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
frames_shown: diagnostics
|
||||||
|
.then(|| stats.frames_shown.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
frames_dropped: diagnostics
|
||||||
|
.then(|| stats.frames_dropped.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
loss_percent: diagnostics
|
||||||
|
.then(|| format!("{:.1}%", stats.loss_percent()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
datagrams: diagnostics
|
||||||
|
.then(|| stats.datagrams.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
gap_ms: stats
|
gap_ms: stats
|
||||||
.last_interval
|
.last_interval
|
||||||
|
.filter(|_| diagnostics)
|
||||||
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
||||||
.unwrap_or_else(|| "-".to_string())
|
.unwrap_or_default()
|
||||||
.into(),
|
.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-1
@@ -185,6 +185,17 @@ impl Discovery {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 只刷新存活时间戳,不改地址或固件版本。
|
||||||
|
///
|
||||||
|
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
|
||||||
|
fn touch(&self, id: u32) {
|
||||||
|
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||||
|
|
||||||
|
if let Some(info) = cameras.get_mut(&id) {
|
||||||
|
info.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
|
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
|
||||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||||
|
|
||||||
@@ -239,6 +250,18 @@ pub struct Receiver {
|
|||||||
frames: Arc<LatestFrames>,
|
frames: Arc<LatestFrames>,
|
||||||
role: SubscribeRole,
|
role: SubscribeRole,
|
||||||
camera: Ipv4Addr,
|
camera: Ipv4Addr,
|
||||||
|
/// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。
|
||||||
|
///
|
||||||
|
/// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面
|
||||||
|
/// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会
|
||||||
|
/// 出现在任何统计里。
|
||||||
|
///
|
||||||
|
/// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并
|
||||||
|
/// 解码了。隐藏一行文字不阻止任何事。
|
||||||
|
///
|
||||||
|
/// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上
|
||||||
|
/// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。
|
||||||
|
only_sender: Mutex<Option<u32>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +270,13 @@ impl Receiver {
|
|||||||
///
|
///
|
||||||
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
|
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
|
||||||
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
|
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
|
||||||
pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result<Self> {
|
///
|
||||||
|
/// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。
|
||||||
|
pub fn start(
|
||||||
|
camera: Ipv4Addr,
|
||||||
|
role: SubscribeRole,
|
||||||
|
only_sender: Option<u32>,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
let socket =
|
let socket =
|
||||||
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||||
@@ -262,10 +291,24 @@ impl Receiver {
|
|||||||
frames: Arc::new(LatestFrames::default()),
|
frames: Arc::new(LatestFrames::default()),
|
||||||
role,
|
role,
|
||||||
camera,
|
camera,
|
||||||
|
only_sender: Mutex::new(only_sender),
|
||||||
stop: Arc::new(AtomicBool::new(false)),
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定之后切换限制目标,或解除限制。
|
||||||
|
///
|
||||||
|
/// 队伍端在选择界面里点定一块板子时调用:此前要能看到所有候选(否则无从选
|
||||||
|
/// 起),此后只看这一块。
|
||||||
|
pub fn restrict_to(&self, only_sender: Option<u32>) {
|
||||||
|
*self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前限制到哪一块板子。
|
||||||
|
fn sender_limit(&self) -> Option<u32> {
|
||||||
|
*self.only_sender.lock().expect("only_sender mutex poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn discovery(&self) -> Arc<Discovery> {
|
pub fn discovery(&self) -> Arc<Discovery> {
|
||||||
Arc::clone(&self.discovery)
|
Arc::clone(&self.discovery)
|
||||||
}
|
}
|
||||||
@@ -348,9 +391,22 @@ impl Receiver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let limit = self.sender_limit();
|
||||||
|
|
||||||
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
|
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
|
||||||
// 头里的 sender_id 决定。
|
// 头里的 sender_id 决定。
|
||||||
if let Some(hello) = protocol::Hello::decode(bytes) {
|
if let Some(hello) = protocol::Hello::decode(bytes) {
|
||||||
|
// 限制生效时不记录其他板子。理由不只是"不显示":如果把它们记进
|
||||||
|
// discovery,`--list` 式的输出、槽位排序、界面上任何一处遍历 cameras()
|
||||||
|
// 的地方都会间接泄露场上还有谁。
|
||||||
|
if let (Some(only), SocketAddr::V4(v4)) = (limit, from) {
|
||||||
|
if hello.sender_id != only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let SocketAddr::V4(v4) = from {
|
if let SocketAddr::V4(v4) = from {
|
||||||
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
|
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
|
||||||
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
||||||
@@ -372,6 +428,22 @@ impl Receiver {
|
|||||||
|
|
||||||
let id = frag.header.sender_id;
|
let id = frag.header.sender_id;
|
||||||
|
|
||||||
|
// 视频同样要过闸门。对手的板子一旦把流推到这个端口(IP 已被广播泄露,
|
||||||
|
// 端口是公开常量),它的数据不会进任何重组器。
|
||||||
|
if let Some(only) = limit {
|
||||||
|
if id != only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频帧也算存活证据。
|
||||||
|
//
|
||||||
|
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是出现一个
|
||||||
|
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。真板子每 2
|
||||||
|
// 秒广播一次所以不易撞上,但它本身就是设计错误——正在推流的板子比广播更
|
||||||
|
// 可靠地证明自己活着。
|
||||||
|
self.discovery.touch(id);
|
||||||
|
|
||||||
match assemblers
|
match assemblers
|
||||||
.entry(id)
|
.entry(id)
|
||||||
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
|
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
|
||||||
|
|||||||
+325
-191
@@ -1,6 +1,10 @@
|
|||||||
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
||||||
|
|
||||||
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
|
/* 一块画面的读数。
|
||||||
|
*
|
||||||
|
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
|
||||||
|
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
|
||||||
|
* 界面也不显示它们。裁判端始终显示。 */
|
||||||
export struct CameraStats {
|
export struct CameraStats {
|
||||||
// 在线状态
|
// 在线状态
|
||||||
online: bool,
|
online: bool,
|
||||||
@@ -43,9 +47,260 @@ export struct CameraChoice {
|
|||||||
online: bool,
|
online: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 队伍端
|
/* ------------------------------------------------------------------ 视频窗格
|
||||||
|
*
|
||||||
|
* 一路画面 + 滚轮缩放 + 拖动平移。两种身份共用这个组件。
|
||||||
|
*
|
||||||
|
* 缩放的实现方式:把 `Image` 的 `width`/`height` 按 `zoom` 放大后放进一个
|
||||||
|
* `clip: true` 的容器里,靠 `offset-x`/`offset-y` 平移。Slint 的 `Image` 没有
|
||||||
|
* 缩放因子属性,所以只能用这个办法——它也顺带让"放大到超出窗口"成为可能。
|
||||||
|
*
|
||||||
|
* `image-fit: contain` 让缩放到 1.0 时画面完整可见且不变形;放大后
|
||||||
|
* `image-fit: fill` 才会跟着缩放,否则画面尺寸固定在 contain 算出的尺寸上,
|
||||||
|
* 放大看起来毫无反应。
|
||||||
|
*/
|
||||||
|
component VideoPane inherits Rectangle {
|
||||||
|
// 画面
|
||||||
|
in property <image> frame;
|
||||||
|
// 没有画面时显示的提示(例如槽位空着)
|
||||||
|
in property <string> empty-text;
|
||||||
|
|
||||||
/*
|
background: #000000;
|
||||||
|
// 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
// 缩放与平移。zoom = 1.0 是"完整可见"。
|
||||||
|
in-out property <float> zoom: 1.0;
|
||||||
|
in-out property <length> offset-x: 0px;
|
||||||
|
in-out property <length> offset-y: 0px;
|
||||||
|
|
||||||
|
// 放大到几倍为止。再多也看不清了,只是把像素拉大。
|
||||||
|
in property <float> max-zoom: 8.0;
|
||||||
|
in property <float> step: 1.25;
|
||||||
|
|
||||||
|
// 拖动时的起点,由 TouchArea 记录。
|
||||||
|
private property <length> drag-start-x: 0px;
|
||||||
|
private property <length> drag-start-y: 0px;
|
||||||
|
private property <length> drag-origin-x: 0px;
|
||||||
|
private property <length> drag-origin-y: 0px;
|
||||||
|
|
||||||
|
public function reset-view() {
|
||||||
|
root.zoom = 1.0;
|
||||||
|
root.offset-x = 0px;
|
||||||
|
root.offset-y = 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
source: root.frame;
|
||||||
|
// contain:zoom=1 时完整可见且不变形。
|
||||||
|
// fill:放大后跟着容器缩放——只用 contain 的话放大不会有任何视觉变化。
|
||||||
|
image-fit: root.zoom <= 1.0 ? contain : fill;
|
||||||
|
width: parent.width * root.zoom;
|
||||||
|
height: parent.height * root.zoom;
|
||||||
|
x: (parent.width - self.width) / 2 + root.offset-x;
|
||||||
|
y: (parent.height - self.height) / 2 + root.offset-y;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有画面时的占位。裁判端两块板子通常只到一块,空槽位要能说清原因,
|
||||||
|
// 而不是留一块黑。
|
||||||
|
//
|
||||||
|
// `absolute` 布局:它不参与父级的尺寸需求计算。之前它不是,于是"已连接,
|
||||||
|
// 等待画面…"这行字的长度决定了这一格要多少宽度,裁判端两格就永远不等宽
|
||||||
|
// (实测 55% / 45%)——文字长短本不该影响画面大小。
|
||||||
|
if root.empty-text != "": Text {
|
||||||
|
text: root.empty-text;
|
||||||
|
color: #5a5a64;
|
||||||
|
font-size: 14px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
// 占满整个窗格,文字居中,但不参与布局。
|
||||||
|
x: 0;
|
||||||
|
y: parent.height / 2 - self.height / 2;
|
||||||
|
width: parent.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放指示:只在放大时出现,否则一直占着位置。
|
||||||
|
//
|
||||||
|
// 同样是绝对定位(给出 x/y),否则它一出现就会改变这一格的尺寸需求,把两路
|
||||||
|
// 不等宽的问题重新引进来——而且是在用户放大之后才出现,最难排查。
|
||||||
|
if root.zoom > 1.05: Rectangle {
|
||||||
|
background: #000000aa;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 92px;
|
||||||
|
height: 26px;
|
||||||
|
x: parent.width - self.width - 10px;
|
||||||
|
y: 10px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "x" + root.zoom;
|
||||||
|
color: #d0d0d8;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset-view 按钮:只在放大/平移后出现,给一个回到全景的出口。
|
||||||
|
if root.zoom > 1.05 || root.offset-x != 0px || root.offset-y != 0px: Rectangle {
|
||||||
|
background: #000000aa;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 76px;
|
||||||
|
height: 26px;
|
||||||
|
x: 10px;
|
||||||
|
y: 10px;
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.reset-view();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "复位";
|
||||||
|
color: #d0d0d8;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚轮与拖动都在最上层。
|
||||||
|
TouchArea {
|
||||||
|
scroll-event(event) => {
|
||||||
|
// delta-y 为正表示向上滚(放大)。
|
||||||
|
if event.delta-y > 0px {
|
||||||
|
root.zoom = Math.min(root.zoom * root.step, root.max-zoom);
|
||||||
|
} else if event.delta-y < 0px {
|
||||||
|
root.zoom = Math.max(root.zoom / root.step, 1.0);
|
||||||
|
}
|
||||||
|
// 缩回 1.0 时顺便归位:此时画面完整可见,平移没有意义,
|
||||||
|
// 留着会让下一次放大从偏移位置开始,看起来像"跳了一下"。
|
||||||
|
if root.zoom <= 1.0 {
|
||||||
|
root.offset-x = 0px;
|
||||||
|
root.offset-y = 0px;
|
||||||
|
}
|
||||||
|
return EventResult.accept;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动平移。
|
||||||
|
//
|
||||||
|
// 用 `pointer-event` 而不是 `pressed` / `moved`:TouchArea 的 `pressed` 是
|
||||||
|
// **属性**(可以读 `self.pressed`)而不是回调,写成 `pressed => {...}` 编译
|
||||||
|
// 不过。按下(Down)时记起点和当前偏移,移动(Move)时按增量更新。
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down {
|
||||||
|
root.drag-start-x = self.mouse-x;
|
||||||
|
root.drag-start-y = self.mouse-y;
|
||||||
|
root.drag-origin-x = root.offset-x;
|
||||||
|
root.drag-origin-y = root.offset-y;
|
||||||
|
} else if event.kind == PointerEventKind.move && self.pressed {
|
||||||
|
// 只在放大后生效——zoom=1 时画面完整可见,没有可移动的空间。
|
||||||
|
if root.zoom > 1.0 {
|
||||||
|
root.offset-x = root.drag-origin-x
|
||||||
|
+ (self.mouse-x - root.drag-start-x);
|
||||||
|
root.offset-y = root.drag-origin-y
|
||||||
|
+ (self.mouse-y - root.drag-start-y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EventResult.accept;
|
||||||
|
}
|
||||||
|
|
||||||
|
double-clicked => {
|
||||||
|
root.reset-view();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放大后用抓取光标提示可拖动。
|
||||||
|
mouse-cursor: root.zoom > 1.0 ? move : default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一路画面的下方信息条。两种身份共用。
|
||||||
|
*
|
||||||
|
* 技术读数(fps / shown / dropped)在队伍端 release 下为空字符串,`visible`
|
||||||
|
* 让整块消失而不是留一条空行。 */
|
||||||
|
component StatusBar inherits Rectangle {
|
||||||
|
in property <CameraStats> stats;
|
||||||
|
in property <TeamHealth> health;
|
||||||
|
// 血格子比状态栏里的字宽,裁判端并排时用
|
||||||
|
in property <length> cell-width: 34px;
|
||||||
|
in property <length> cell-height: 12px;
|
||||||
|
// 队伍端那一条更高一些,因为它独占一行血量
|
||||||
|
in property <bool> wide: false;
|
||||||
|
|
||||||
|
background: #1a1a20;
|
||||||
|
height: wide ? 72px : 78px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 16px;
|
||||||
|
|
||||||
|
// 左:队伍名 + 血量格子
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 0;
|
||||||
|
spacing: 5px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.health.team-name;
|
||||||
|
color: #e8e8ec;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding: 0;
|
||||||
|
spacing: 5px;
|
||||||
|
|
||||||
|
for hp[index] in root.health.hps: Rectangle {
|
||||||
|
width: root.cell-width;
|
||||||
|
height: root.cell-height;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: root.health.available ? #4a4a55 : #2f2f38;
|
||||||
|
background: root.health.available ? #6cc96c : #17171c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右:状态 + 技术读数
|
||||||
|
//
|
||||||
|
// VerticalLayout 没有 horizontal-alignment 属性(右对齐要落在里面的 Text
|
||||||
|
// 上)。给它 preferred-width 而不是 stretch,让它按内容宽度占位。
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 0;
|
||||||
|
spacing: 3px;
|
||||||
|
preferred-width: root.wide ? 340px : 300px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.stats.status-text
|
||||||
|
+ (root.stats.fps != "" ? " " + root.stats.fps + " fps" : "")
|
||||||
|
+ (root.stats.resolution != "" ? " " + root.stats.resolution : "");
|
||||||
|
color: root.stats.online ? #e8e8ec : #e0a060;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-alignment: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技术读数整行在 release 下消失,不留空行。
|
||||||
|
if root.stats.frames-shown != "": Text {
|
||||||
|
text: "shown " + root.stats.frames-shown
|
||||||
|
+ (root.stats.frames-dropped != ""
|
||||||
|
? " dropped " + root.stats.frames-dropped
|
||||||
|
+ " (" + root.stats.loss-percent + ")"
|
||||||
|
: "")
|
||||||
|
+ (root.stats.gap-ms != "" ? " gap " + root.stats.gap-ms : "")
|
||||||
|
+ (root.stats.frame-bytes != "" ? " " + root.stats.frame-bytes : "");
|
||||||
|
color: root.stats.frames-dropped == "0" ? #7e7e89 : #e0a060;
|
||||||
|
font-size: 12px;
|
||||||
|
horizontal-alignment: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 队伍端
|
||||||
|
*
|
||||||
* 队伍终端:一块画面 + 自己那一边的血量。
|
* 队伍终端:一块画面 + 自己那一边的血量。
|
||||||
*
|
*
|
||||||
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
||||||
@@ -53,8 +308,13 @@ export struct CameraChoice {
|
|||||||
* 情况。 */
|
* 情况。 */
|
||||||
export component TeamApp inherits Window {
|
export component TeamApp inherits Window {
|
||||||
title: "cam-client — 队伍";
|
title: "cam-client — 队伍";
|
||||||
preferred-width: 1000px;
|
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
|
||||||
preferred-height: 720px;
|
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
|
||||||
|
preferred-width: 1280px;
|
||||||
|
preferred-height: 800px;
|
||||||
|
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
|
||||||
|
min-width: 900px;
|
||||||
|
min-height: 600px;
|
||||||
|
|
||||||
in property <image> frame;
|
in property <image> frame;
|
||||||
in property <CameraStats> stats;
|
in property <CameraStats> stats;
|
||||||
@@ -64,101 +324,33 @@ export component TeamApp inherits Window {
|
|||||||
in property <[CameraChoice]> choices;
|
in property <[CameraChoice]> choices;
|
||||||
in property <bool> choosing;
|
in property <bool> choosing;
|
||||||
|
|
||||||
// 选中了列表里的第 N 项。
|
|
||||||
//
|
|
||||||
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
|
|
||||||
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
|
|
||||||
// 信息——.slint 里的模型只是给界面看的。
|
|
||||||
callback pick(int);
|
callback pick(int);
|
||||||
|
|
||||||
background: #101014;
|
background: #101014;
|
||||||
|
|
||||||
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
|
|
||||||
if !root.choosing: VerticalLayout {
|
if !root.choosing: VerticalLayout {
|
||||||
spacing: 0;
|
spacing: 0;
|
||||||
|
|
||||||
HorizontalLayout {
|
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
|
||||||
padding: 0;
|
// 视频终端里每一像素都可能是有用信息。
|
||||||
spacing: 0;
|
VideoPane {
|
||||||
|
frame: root.frame;
|
||||||
Rectangle {
|
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
|
||||||
background: #0a0a0d;
|
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
|
|
||||||
frame := Image {
|
|
||||||
source: root.frame;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部一条:左边自己这边的血量,右边这一路的统计。
|
|
||||||
Rectangle {
|
|
||||||
background: #1c1c22;
|
|
||||||
height: 72px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 14px;
|
|
||||||
spacing: 24px;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health.team-name;
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装甲板还没接上,所以是一排占位而不是四个数字。
|
StatusBar {
|
||||||
HorizontalLayout {
|
stats: root.stats;
|
||||||
padding: 0;
|
health: root.health;
|
||||||
spacing: 6px;
|
wide: true;
|
||||||
|
|
||||||
for hp in root.health.hps: Rectangle {
|
|
||||||
width: 34px;
|
|
||||||
height: 12px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
preferred-width: 320px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.stats.status-text + " " + root.stats.fps + " fps "
|
|
||||||
+ root.stats.resolution + " " + root.stats.frame-bytes;
|
|
||||||
color: root.stats.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats.frames-shown
|
|
||||||
+ " dropped " + root.stats.frames-dropped
|
|
||||||
+ " (" + root.stats.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats.gap-ms;
|
|
||||||
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
||||||
|
// release 下这行也保留——它不泄露任何对手信息。
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: #14141a;
|
background: #14141a;
|
||||||
height: 26px;
|
height: 24px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.health.placeholder;
|
text: root.health.placeholder;
|
||||||
@@ -173,7 +365,7 @@ export component TeamApp inherits Window {
|
|||||||
// 选择界面
|
// 选择界面
|
||||||
if root.choosing: VerticalLayout {
|
if root.choosing: VerticalLayout {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
spacing: 16px;
|
spacing: 14px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "选择要观看的摄像头";
|
text: "选择要观看的摄像头";
|
||||||
@@ -189,7 +381,7 @@ export component TeamApp inherits Window {
|
|||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 8px; background: transparent; }
|
Rectangle { height: 6px; background: transparent; }
|
||||||
|
|
||||||
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
|
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
|
||||||
// 点击回调;TouchArea 才是带交互的元素。
|
// 点击回调;TouchArea 才是带交互的元素。
|
||||||
@@ -252,16 +444,18 @@ export component TeamApp inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 裁判端
|
/* ---------------------------------------------------------------- 裁判端
|
||||||
|
*
|
||||||
/* 裁判终端:两块画面左右并排 + 双方血量。
|
* 裁判终端:两块画面左右并排 + 双方血量。
|
||||||
*
|
*
|
||||||
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
|
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
|
||||||
* 并排对称。 */
|
* 并排对称。 */
|
||||||
export component RefereeApp inherits Window {
|
export component RefereeApp inherits Window {
|
||||||
title: "cam-client — 裁判系统";
|
title: "cam-client — 裁判系统";
|
||||||
preferred-width: 1400px;
|
preferred-width: 1600px;
|
||||||
preferred-height: 800px;
|
preferred-height: 900px;
|
||||||
|
min-width: 1000px;
|
||||||
|
min-height: 640px;
|
||||||
|
|
||||||
in property <image> frame-a;
|
in property <image> frame-a;
|
||||||
in property <image> frame-b;
|
in property <image> frame-b;
|
||||||
@@ -270,139 +464,79 @@ export component RefereeApp inherits Window {
|
|||||||
in property <TeamHealth> health-a;
|
in property <TeamHealth> health-a;
|
||||||
in property <TeamHealth> health-b;
|
in property <TeamHealth> health-b;
|
||||||
|
|
||||||
|
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
|
||||||
|
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
|
||||||
|
|
||||||
background: #101014;
|
background: #101014;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0;
|
spacing: 0;
|
||||||
|
|
||||||
// 两路画面等宽并排,各占一半。
|
// 两路画面等宽并排。
|
||||||
|
//
|
||||||
|
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
|
||||||
|
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
|
||||||
|
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
|
||||||
|
// 十几个像素,右边就少十几个——实测 55% / 45%。
|
||||||
|
//
|
||||||
|
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
|
||||||
|
//
|
||||||
|
// 解法是让两格的需求**只由画面决定**,与文字无关:
|
||||||
|
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
|
||||||
|
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
|
||||||
|
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
|
|
||||||
Rectangle {
|
VideoPane {
|
||||||
background: #0a0a0d;
|
frame: root.frame-a;
|
||||||
|
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
preferred-width: 480px;
|
||||||
frame-a := Image {
|
min-width: 320px;
|
||||||
source: root.frame-a;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
VideoPane {
|
||||||
background: #0a0a0d;
|
frame: root.frame-b;
|
||||||
|
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
|
||||||
|
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
preferred-width: 480px;
|
||||||
frame-b := Image {
|
min-width: 320px;
|
||||||
source: root.frame-b;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每路画面下方的队伍条:名字、血量、统计。
|
// 每路画面下方的队伍条:名字、血量、统计。
|
||||||
|
//
|
||||||
|
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
|
|
||||||
Rectangle {
|
StatusBar {
|
||||||
background: #1c1c22;
|
stats: root.stats-a;
|
||||||
height: 78px;
|
health: root.health-a;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
preferred-width: 480px;
|
||||||
VerticalLayout {
|
min-width: 320px;
|
||||||
padding: 10px;
|
|
||||||
spacing: 4px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-a.team-name + " " + root.stats-a.status-text
|
|
||||||
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
|
|
||||||
color: root.stats-a.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
StatusBar {
|
||||||
padding: 0;
|
stats: root.stats-b;
|
||||||
spacing: 6px;
|
health: root.health-b;
|
||||||
|
|
||||||
for hp in root.health-a.hps: Rectangle {
|
|
||||||
width: 40px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health-a.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health-a.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats-a.frames-shown
|
|
||||||
+ " dropped " + root.stats-a.frames-dropped
|
|
||||||
+ " (" + root.stats-a.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats-a.gap-ms;
|
|
||||||
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #1c1c22;
|
|
||||||
height: 78px;
|
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
preferred-width: 480px;
|
||||||
VerticalLayout {
|
min-width: 320px;
|
||||||
padding: 10px;
|
|
||||||
spacing: 4px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-b.team-name + " " + root.stats-b.status-text
|
|
||||||
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
|
|
||||||
color: root.stats-b.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
for hp in root.health-b.hps: Rectangle {
|
|
||||||
width: 40px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health-b.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health-b.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats-b.frames-shown
|
|
||||||
+ " dropped " + root.stats-b.frames-dropped
|
|
||||||
+ " (" + root.stats-b.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats-b.gap-ms;
|
|
||||||
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: #14141a;
|
background: #14141a;
|
||||||
height: 26px;
|
height: 24px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.health-a.placeholder;
|
text: root.health-a.placeholder;
|
||||||
|
|||||||
Reference in New Issue
Block a user