|
| 1 | +/** |
| 2 | + * CaptureFloatPanel — 采集浮窗(v0.12.0 M6,采集体验债)。 |
| 3 | + * |
| 4 | + * @ai-context: 采集中全屏看视频时主面板被遮挡——常驻悬浮小窗(alwaysOnTop) |
| 5 | + * 顶部状态/时长、中部最近转写、底部最近画面要点、控制按钮。 |
| 6 | + * 与主面板共用 useLiveSessionEvents hook(同一 live:* 数据流)。 |
| 7 | + * 控制按钮走真实命令(暂停/继续/停止/回主窗);"标记"无后端命令, |
| 8 | + * 按 YAGNI 不预排虚假按钮。 |
| 9 | + * |
| 10 | + * @line-limit-exemption: 浮窗内容密度高(360×240 内状态/转写/画面/控制分区), |
| 11 | + * 登记 docs/standards/line-limit-exemptions.md。 |
| 12 | + */ |
| 13 | +import { invoke } from "@tauri-apps/api/core"; |
| 14 | +import { useLiveSessionEvents } from "../hooks/useLiveSessionEvents"; |
| 15 | + |
| 16 | +function fmtTime(ms: number): string { |
| 17 | + const s = Math.floor(ms / 1000); |
| 18 | + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; |
| 19 | +} |
| 20 | + |
| 21 | +const btn: React.CSSProperties = { |
| 22 | + padding: "3px 8px", |
| 23 | + fontSize: 11, |
| 24 | + borderRadius: 6, |
| 25 | + border: "1px solid #d1d5db", |
| 26 | + background: "#fff", |
| 27 | + cursor: "pointer", |
| 28 | + flex: 1, |
| 29 | +}; |
| 30 | + |
| 31 | +/** 最近展示条数(360×240 浮窗——只给最近 3 条转写 + 2 条画面) */ |
| 32 | +const SHOW_TRANSCRIPT = 3; |
| 33 | +const SHOW_OCR = 2; |
| 34 | + |
| 35 | +export default function CaptureFloatPanel() { |
| 36 | + const { phase, transcripts, partials, counts, ocrLines, elapsedMs, info } = |
| 37 | + useLiveSessionEvents(); |
| 38 | + const paused = phase.startsWith("⏸"); |
| 39 | + |
| 40 | + const togglePause = () => { |
| 41 | + void invoke(paused ? "resume_live_session" : "pause_live_session").catch((e) => |
| 42 | + console.warn("[capture-float] 暂停/继续失败:", e), |
| 43 | + ); |
| 44 | + }; |
| 45 | + const stop = () => { |
| 46 | + void invoke("stop_live_session") |
| 47 | + .catch((e) => console.warn("[capture-float] 停止采集失败:", e)) |
| 48 | + .then(() => void invoke("close_capture_float").catch(() => undefined)); |
| 49 | + }; |
| 50 | + const backToMain = () => { |
| 51 | + void invoke("close_capture_float").catch(() => undefined); |
| 52 | + }; |
| 53 | + |
| 54 | + return ( |
| 55 | + <div |
| 56 | + style={{ |
| 57 | + height: "100vh", |
| 58 | + boxSizing: "border-box", |
| 59 | + display: "flex", |
| 60 | + flexDirection: "column", |
| 61 | + fontFamily: "system-ui, sans-serif", |
| 62 | + fontSize: 11, |
| 63 | + color: "#1f2937", |
| 64 | + background: "rgba(255,255,255,0.95)", |
| 65 | + border: "1px solid #e5e7eb", |
| 66 | + borderRadius: 8, |
| 67 | + overflow: "hidden", |
| 68 | + }} |
| 69 | + > |
| 70 | + {/* 顶部:状态 + 时长 + 平台 */} |
| 71 | + <div |
| 72 | + style={{ |
| 73 | + display: "flex", |
| 74 | + alignItems: "center", |
| 75 | + gap: 8, |
| 76 | + padding: "6px 10px", |
| 77 | + borderBottom: "1px solid #e5e7eb", |
| 78 | + fontSize: 11, |
| 79 | + flexShrink: 0, |
| 80 | + }} |
| 81 | + > |
| 82 | + <span style={{ fontWeight: 600, color: paused ? "#b45309" : "#dc2626" }}> |
| 83 | + {phase === "正在初始化…" ? "初始化…" : phase} |
| 84 | + </span> |
| 85 | + <span style={{ color: "#6b7280", fontVariantNumeric: "tabular-nums" }}>⏱ {fmtTime(elapsedMs)}</span> |
| 86 | + <span style={{ marginLeft: "auto", color: "#6b7280" }}>{info?.platform ?? ""}</span> |
| 87 | + </div> |
| 88 | + |
| 89 | + {/* 中部:最近转写 */} |
| 90 | + <div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "6px 10px" }}> |
| 91 | + {transcripts.slice(-SHOW_TRANSCRIPT).length === 0 && partials.length === 0 && ( |
| 92 | + <p style={{ margin: 0, color: "#9ca3af" }}>等待识别…</p> |
| 93 | + )} |
| 94 | + {transcripts.slice(-SHOW_TRANSCRIPT).map((t) => ( |
| 95 | + <div key={t.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5, marginBottom: 2 }}> |
| 96 | + <span style={{ fontSize: 10, color: "#9ca3af", width: 36, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}> |
| 97 | + {fmtTime(t.time)} |
| 98 | + </span> |
| 99 | + <span style={{ color: t.source === "subtitle" ? "#0f766e" : "#374151", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> |
| 100 | + {t.text} |
| 101 | + </span> |
| 102 | + </div> |
| 103 | + ))} |
| 104 | + {partials.slice(-2).map((p) => ( |
| 105 | + <div key={p.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5, marginBottom: 2 }}> |
| 106 | + <span style={{ fontSize: 10, width: 36, flexShrink: 0 }} /> |
| 107 | + <span style={{ color: p.committed ? "#374151" : "#9ca3af", fontStyle: p.committed ? "normal" : "italic", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> |
| 108 | + {p.text} |
| 109 | + </span> |
| 110 | + </div> |
| 111 | + ))} |
| 112 | + </div> |
| 113 | + |
| 114 | + {/* 底部:最近画面要点 + 计数 */} |
| 115 | + <div style={{ padding: "6px 10px", borderTop: "1px solid #e5e7eb", flexShrink: 0 }}> |
| 116 | + <div style={{ display: "flex", gap: 8, color: "#6b7280", marginBottom: 4 }}> |
| 117 | + <span>字幕 {counts.subtitle}</span> |
| 118 | + <span>语音 {counts.asr}</span> |
| 119 | + <span style={{ color: "#2563eb" }}>画面 {counts.ocr}</span> |
| 120 | + </div> |
| 121 | + {ocrLines.slice(-SHOW_OCR).map((o) => ( |
| 122 | + <div key={o.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5 }}> |
| 123 | + <span style={{ fontSize: 10, color: "#2563eb", flexShrink: 0, fontWeight: 600 }}>屏{o.screenId}</span> |
| 124 | + <span style={{ color: "#1e40af", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{o.text}</span> |
| 125 | + </div> |
| 126 | + ))} |
| 127 | + {/* 控制按钮(暂停/继续 · 停止 · 回主窗) */} |
| 128 | + <div style={{ display: "flex", gap: 6, marginTop: 6 }}> |
| 129 | + <button style={{ ...btn, background: paused ? "#0d9488" : "#f59e0b", color: "#fff", border: "none" }} onClick={togglePause}> |
| 130 | + {paused ? "▶ 继续" : "⏸ 暂停"} |
| 131 | + </button> |
| 132 | + <button style={{ ...btn, background: "#dc2626", color: "#fff", border: "none" }} onClick={stop}> |
| 133 | + ⏹ 停止 |
| 134 | + </button> |
| 135 | + <button style={{ ...btn }} onClick={backToMain}> |
| 136 | + ⌂ 回主窗 |
| 137 | + </button> |
| 138 | + </div> |
| 139 | + </div> |
| 140 | + </div> |
| 141 | + ); |
| 142 | +} |
0 commit comments