将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。 - fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示 - test(client): 按文件名核对媒体类型,避免依赖时间戳排序 验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
263 lines
9.0 KiB
TypeScript
263 lines
9.0 KiB
TypeScript
/**
|
||
* 录制、截图与录下来的东西。
|
||
*
|
||
* # 回放为什么可能失败
|
||
*
|
||
* 界面内回放走 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>
|
||
);
|
||
}
|