Files
simba-rmic-2026/desktop/src/components/MediaPanel.tsx
T
sloray 6a0eb14b28
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。

- fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示
- test(client): 按文件名核对媒体类型,避免依赖时间戳排序

验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
2026-10-19 11:26:26 +08:00

263 lines
9.0 KiB
TypeScript
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.
/**
* 录制、截图与录下来的东西。
*
* # 回放为什么可能失败
*
* 界面内回放走 Tauri 的资源协议,而那条协议只对 `tauri.conf.json` 里
* `assetProtocol.scope` 白名单的目录生效。默认录制目录在白名单里;如果现场把输出目录
* 改到别处,`<video>` 会加载失败——那时退回到"用系统播放器打开",而不是让人以为文件坏了。
*
* # 为什么录制中是"条"而不是一行字
*
* 录制这件事最贵的错误是"以为在录,其实没在录"。所以正在录的每一路都占一条醒目的横条:
* 跳动的红点、已写帧数、已经录了多久、写盘跟不上时丢了几帧。这些数字在录完之后就再也
* 拿不到了,只能在当下看。
*/
import { useCallback, useEffect, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { api } from "../lib/ipc";
import { bytes, since } from "../lib/format";
import type { MediaFileDto, Snapshot } from "../lib/types";
interface Props {
snapshot: Snapshot;
onError: (message: string) => void;
onInfo: (message: string) => void;
}
export function MediaPanel({ snapshot, onError, onInfo }: Props) {
const [files, setFiles] = useState<MediaFileDto[]>([]);
const [playing, setPlaying] = useState<MediaFileDto | null>(null);
const [playbackFailed, setPlaybackFailed] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(() => {
void api.mediaFiles().then(setFiles, () => undefined);
}, []);
useEffect(() => {
refresh();
// 录制中每 2 秒刷一次列表:刚录完的那一段应该自己出现。
const timer = window.setInterval(refresh, 2000);
return () => window.clearInterval(timer);
}, [refresh]);
const active = snapshot.recordings.filter((recording) => recording.active);
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
const guard = (action: Promise<unknown>, onSuccess?: () => void) => {
setBusy(true);
void action
.then(() => {
onSuccess?.();
refresh();
return api.refresh();
})
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false));
};
const shotTargets = [snapshot.slots.cameraA, snapshot.slots.cameraB].filter(
(id): id is number => id !== null,
);
return (
<div className="media">
<div className="media__actions">
<button
type="button"
className="btn btn--primary"
disabled={busy || cameras.length === 0}
title={cameras.length === 0 ? "暂无在线图传板" : undefined}
onClick={() => {
guard(api.startRecording(cameras), () =>
onInfo("已开始录制。退出程序时会先结束录制并保存文件。"),
);
}}
>
<span className="media__rec-glyph" aria-hidden="true" />
录制全部在线画面
</button>
<button
type="button"
className="btn"
disabled={busy || active.length === 0}
onClick={() => {
guard(api.stopRecording([]));
}}
>
停止全部录制
</button>
<span className="media__divider" aria-hidden="true" />
{shotTargets.map((camera) => (
<button
key={camera}
type="button"
className="btn btn--ghost"
disabled={busy}
onClick={() => {
guard(api.screenshot(camera), () =>
onInfo(
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(已保存到录制目录的 shots/)。`,
),
);
}}
>
截图
<span className="num media__cam">0x{camera.toString(16).toUpperCase().padStart(8, "0")}</span>
</button>
))}
<span className="media__spacer" />
<button
type="button"
className="btn btn--quiet"
onClick={() => {
void api
.reveal(snapshot.config.recordingDir)
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
}}
>
打开录制目录
</button>
</div>
{active.length > 0 && (
<div className="media__strips">
{active.map((recording) => (
<div key={recording.camera} className="strip">
<span className="strip__pulse" aria-hidden="true" />
<span className="strip__label label">REC</span>
<span className="strip__cam num">{recording.cameraText}</span>
<span className="strip__stats num">
{recording.framesWritten} 帧
<span className="strip__dot" />
{since(recording.startedMs)}
<span className="strip__dot" />
{recording.inputFps.toFixed(1)} fps 输入
</span>
{recording.framesDropped > 0 && (
<span
className="strip__warn"
title="写入速度不足时丢弃的帧数,可能影响回放速度。"
>
丢 {recording.framesDropped} 帧
</span>
)}
{recording.error && <span className="strip__error">ffmpeg:{recording.error}</span>}
<button
type="button"
className="btn btn--ghost"
onClick={() => {
guard(api.stopRecording([recording.camera]));
}}
>
停止
</button>
</div>
))}
</div>
)}
<div className="media__list">
<div className="media__head">
<span className="media__col media__col--kind">类型</span>
<span className="media__col">文件</span>
<span className="media__col media__col--size">大小</span>
<span className="media__col media__col--acts">操作</span>
</div>
{files.length === 0 ? (
<div className="media__empty">录制目录里还没有文件。</div>
) : (
files.map((file) => (
<div key={file.path} className="media__row">
<span className="media__col media__col--kind">
<span className={`media__kind media__kind--${file.kind}`}>
{file.kind === "video" ? "MP4" : "PNG"}
</span>
</span>
<span className="media__col media__name" title={file.path}>
{file.name}
</span>
<span className="media__col media__col--size num">{bytes(file.bytes)}</span>
<span className="media__col media__col--acts">
<button
type="button"
className="btn btn--quiet"
onClick={() => {
setPlaybackFailed(false);
setPlaying(file.kind === "video" ? file : null);
if (file.kind === "image") {
void api.openFile(file.path).catch(() => undefined);
}
}}
>
{file.kind === "video" ? "播放" : "打开"}
</button>
<button
type="button"
className="btn btn--quiet"
onClick={() => {
void api
.reveal(file.path)
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
}}
>
定位
</button>
</span>
</div>
))
)}
</div>
{playing && (
<div className="playback">
<div className="playback__head">
<span className="playback__label label">PLAYBACK</span>
<span className="playback__name num">{playing.name}</span>
<span className="playback__spacer" />
<button type="button" className="btn btn--quiet" onClick={() => setPlaying(null)}>
关闭
</button>
</div>
{playbackFailed ? (
<div className="playback__fallback">
<p className="playback__warn">
无法在界面内回放,可能是录制目录不在允许访问的范围内。可改用默认录制目录,
或用系统播放器打开。
</p>
<button
type="button"
className="btn btn--primary"
onClick={() => void api.openFile(playing.path).catch(() => undefined)}
>
用系统播放器打开
</button>
</div>
) : (
<video
className="playback__video"
src={convertFileSrc(playing.path)}
controls
autoPlay
onError={() => setPlaybackFailed(true)}
/>
)}
</div>
)}
</div>
);
}