Files
ESP32S3_vt/client/ui/camera.slint
T
Horldsence 3631a037d8 client: 槽位对应队伍,不再按 id 顺序对号入座
裁判端两个槽位的语义应该是"队伍一 / 队伍二",而不是"第 N 块发现的板子"。之前
两者混为一谈,出现两个都会被现场误读成故障的现象:

1. **两块槽位显示同一块板。** 每轮都从 `online_cameras()` 取前两个 id 填满两
   个槽位,于是只有一块板在场时两边一模一样。裁判会以为两队的图传串了。

2. **板子掉线时画面左右互换。** 低 id 的板子一离线,候选列表缩短,后面那块就
   跳到前面的槽位——同样像串线,而实际上只是排序。

规则改成:每轮最多消费一个候选 id;已填且仍在线的槽位不动;掉线的槽位保留它的
id 并显示"离线",等它回来时还在原位。

空槽的提示文案也改准:左槽"等待队伍一的图传"、右槽"等待队伍二的图传",而不是笼
统的"未收到画面"。

顺带修两个假数字:`frames_shown == 0` 时 `stats.width`/`height` 还是默认值 0,界
面上显示成 `0x0`——一个看起来像分辨率的假分辨率;fps 显示 `0.0` 读起来像"板子
卡死了",而真相是"还没收到"。状态行已经写了"已连接,等待画面…",不该再补一个矛
盾的数字。

关于"别把刚掉线的板子又立刻选回同一个槽位"那条过滤:它只在槽位掉线时起作用,
且会跳过当前 id 去找下一个候选,槽位仍然有机会回来。实测唯一那块在推流的板子正
常占用一个槽位,另一槽保留着在线但不推流的真板子——符合预期。
2026-10-05 21:45:24 +08:00

551 lines
19 KiB
Plaintext
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.
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。
*
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
* 界面也不显示它们。裁判端始终显示。 */
export struct CameraStats {
// 在线状态
online: bool,
status-text: string,
// 实时测量
fps: string,
resolution: string,
frame-bytes: string,
// 累计
frames-shown: string,
frames-dropped: string,
loss-percent: string,
datagrams: string,
// 帧间隔,用于把"卡"和"帧率低"区分开
gap-ms: string,
}
/* 一边的血量(4 块装甲板)。
*
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
* 现场看到 0 会以为对方已经没血了。 */
export struct TeamHealth {
team-name: string,
available: bool,
// 占位符文案
placeholder: string,
// 每块装甲板的血量;available 时有效
hps: [int],
}
/* 首次运行时的摄像头选择。
*
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
export struct CameraChoice {
id-text: string,
addr-text: string,
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;
}
}
}
}
/* ---------------------------------------------------------------- 队伍端
*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
preferred-width: 1280px;
preferred-height: 800px;
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
min-width: 900px;
min-height: 600px;
in property <image> frame;
in property <CameraStats> stats;
in property <TeamHealth> health;
// 选择绑定时显示,其余时候隐藏
in property <[CameraChoice]> choices;
in property <bool> choosing;
callback pick(int);
background: #101014;
if !root.choosing: VerticalLayout {
spacing: 0;
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
// 视频终端里每一像素都可能是有用信息。
VideoPane {
frame: root.frame;
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
vertical-stretch: 1;
horizontal-stretch: 1;
}
StatusBar {
stats: root.stats;
health: root.health;
wide: true;
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
// release 下这行也保留——它不泄露任何对手信息。
Rectangle {
background: #14141a;
height: 24px;
Text {
text: root.health.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 14px;
Text {
text: "选择要观看的摄像头";
color: #e8e8ec;
font-size: 20px;
font-weight: 600;
}
Text {
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
color: #8a8a95;
font-size: 13px;
wrap: word-wrap;
}
Rectangle { height: 6px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
for choice[index] in root.choices: Rectangle {
background: choice.online ? #1c1c22 : #16161b;
border-width: 1px;
border-color: #2a2a33;
height: 56px;
TouchArea {
clicked => {
root.pick(index);
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px;
Text {
text: choice.id-text;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: left;
}
Text {
text: choice.addr-text;
color: #8a8a95;
font-size: 12px;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
}
Text {
text: choice.online ? "在线" : "离线";
color: choice.online ? #7ddc7d : #8a8a95;
font-size: 12px;
vertical-alignment: center;
}
}
}
// 一块都没发现时给出可操作的提示,而不是一个空列表。
if root.choices.length == 0: Rectangle {
background: #1a1418;
border-width: 1px;
border-color: #4a2a35;
height: 64px;
Text {
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
color: #e0a060;
font-size: 13px;
wrap: word-wrap;
vertical-alignment: center;
}
}
}
}
/* ---------------------------------------------------------------- 裁判端
*
* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1600px;
preferred-height: 900px;
min-width: 1000px;
min-height: 640px;
in property <image> frame-a;
in property <image> frame-b;
in property <CameraStats> stats-a;
in property <CameraStats> stats-b;
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排。
//
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
// 十几个像素,右边就少十几个——实测 55% / 45%。
//
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
//
// 解法是让两格的需求**只由画面决定**,与文字无关:
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
HorizontalLayout {
padding: 0;
spacing: 2px;
VideoPane {
frame: root.frame-a;
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
vertical-stretch: 1;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
VideoPane {
frame: root.frame-b;
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
vertical-stretch: 1;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
}
// 每路画面下方的队伍条:名字、血量、统计。
//
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
HorizontalLayout {
padding: 0;
spacing: 2px;
StatusBar {
stats: root.stats-a;
health: root.health-a;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
StatusBar {
stats: root.stats-b;
health: root.health-b;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 24px;
Text {
text: root.health-a.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}