From 62999201452db846246ad106da86264a33b94b6e Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sun, 6 Sep 2026 01:21:31 +0800 Subject: [PATCH] =?UTF-8?q?=E7=82=B9=E7=BB=93=E6=9D=9F=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=85=B3=E6=8E=89=E5=B1=8F=E5=B9=95=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=EF=BC=9B=E6=8B=BE=E5=BF=86=E9=A1=B5=E5=8E=BB=E6=8E=89=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=96=B0=E5=A2=9E=E7=9A=84=20+=20=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 多媒体翻译点「结束」后,系统那条屏幕直播不再挂着 主 App 对「用户从系统面板发起的广播」没有任何控制权——RPBroadcastController 只管 App 内 API 起的那种。唯一能终止它的是扩展自己调 finishBroadcastWithError。所以改成让扩展感知主 App 是否还在: - stopAll() → _teardownCallModeAudio() → stopScreenCapture() 关掉 socket (这条链原本就有) - 扩展侧:曾经连上过、又断开超过 3 秒连不回来 → 判定主 App 收工,结束广播。 没连上过就断开不算——那是「先开广播、还没点开始翻译」的正常情况。 宽限期不能太短,主 App 瞬断会误杀用户正在用的广播。 - 另加 1 秒心跳轮询:只靠 processSampleBuffer 里检查是不够的,视频停了或者 没有任何 App 出声时 audioApp 帧可能长时间不来,那样永远发现不了主 App 已经走了,屏幕上那条红条会一直挂着。 ⚠️ finishBroadcastWithError 必须传 NSError,系统会把 localizedDescription 弹给用户,所以给的是一句正常说明而不是错误信息。 2) 拾忆页去掉右下角的 + 号 内容应当由助手/会议纪要自动落进来,手动新建不是当前需要的能力。 卡片点开编辑保留(TodoScopeList 里)——那是改助手记错的时间/内容用的, 与「手动新建」不是一回事。 Co-Authored-By: Claude Opus 5 --- .../BroadcastAudioSocket.swift | 36 +++++++++++++++++++ .../BroadcastExtension/SampleHandler.swift | 36 +++++++++++++++++++ .../modules/main_tab/views/tabs/todo_tab.dart | 29 +++------------ 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift b/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift index 189c2577..12c983a7 100644 --- a/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift +++ b/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift @@ -27,6 +27,28 @@ final class BroadcastAudioSender { private var fd: Int32 = -1 private var lastConnectAttempt: TimeInterval = 0 + /// 曾经连上过主 App。没连上过就断开不算「主 App 收工了」—— + /// 那是用户先开广播、还没点开始翻译的正常情况。 + private var everConnected = false + + /// 从「曾经连上」变成断开的时刻 + private var disconnectedAt: TimeInterval? + + /// 断开多久之后判定主 App 已经收工。 + /// + /// ⚠️ 不能设太短:主 App 短暂卡顿或切换时可能瞬断,误判会把用户正在用的 + /// 广播直接掐掉。也不能太长,否则点了「结束」还要等半天屏幕上那条红条才消失。 + private let peerGoneGrace: TimeInterval = 3.0 + + /// 判定主 App 已收工时回调一次(由 SampleHandler 用来结束广播) + var onPeerGone: (() -> Void)? + private var peerGoneFired = false + + /// 供外部定时轮询:即使当前没有音频帧进来,也能发现主 App 已经走了。 + func checkPeerAlive() { + _ = ensureConnected() + } + /// 连不上时的重试间隔。 /// /// ⚠️ 不能每帧都试:音频帧几十毫秒一个,主 App 没起来时每帧发起一次 @@ -37,6 +59,15 @@ final class BroadcastAudioSender { private func ensureConnected() -> Bool { if fd >= 0 { return true } let now = Date().timeIntervalSince1970 + // 连过一次又断了、且超过宽限期还连不回来 → 主 App 已经收工(用户点了 + // 「结束」,那边把 socket 关了)。广播只能由扩展自己终止,主 App 对 + // 系统面板发起的广播没有任何控制权,所以这个判断是唯一的收尾时机。 + if everConnected, let since = disconnectedAt, + now - since > peerGoneGrace, !peerGoneFired { + peerGoneFired = true + onPeerGone?() + return false + } if now - lastConnectAttempt < retryInterval { return false } lastConnectAttempt = now @@ -65,12 +96,17 @@ final class BroadcastAudioSender { var nodelay: Int32 = 1 setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &nodelay, socklen_t(MemoryLayout.size)) fd = s + everConnected = true + disconnectedAt = nil return true } private func disconnect() { if fd >= 0 { close(fd) } fd = -1 + if everConnected && disconnectedAt == nil { + disconnectedAt = Date().timeIntervalSince1970 + } } /// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。 diff --git a/apps/client/ios/BroadcastExtension/SampleHandler.swift b/apps/client/ios/BroadcastExtension/SampleHandler.swift index 46bce69e..cd78376a 100644 --- a/apps/client/ios/BroadcastExtension/SampleHandler.swift +++ b/apps/client/ios/BroadcastExtension/SampleHandler.swift @@ -34,12 +34,48 @@ class SampleHandler: RPBroadcastSampleHandler { private var converter: AVAudioConverter? private var converterInputFormat: AVAudioFormat? + /// 主 App 收工时自动结束广播用的心跳。 + /// + /// ⚠️ 不能只靠 processSampleBuffer 里那次检查:视频停了或者没有任何 App + /// 在出声时,`audioApp` 帧可能长时间不来,那样就永远发现不了主 App 已经走了, + /// 屏幕上那条红色录制条会一直挂着。 + private var watchdog: Timer? + override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { NSLog("[BroadcastExtension] broadcastStarted") + sender.onPeerGone = { [weak self] in + self?.finishBecausePeerGone() + } + DispatchQueue.main.async { [weak self] in + self?.watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.sender.checkPeerAlive() + } + } } override func broadcastFinished() { NSLog("[BroadcastExtension] broadcastFinished") + watchdog?.invalidate() + watchdog = nil + } + + /// 主 App 已经停止多媒体翻译 → 结束这次广播。 + /// + /// ⚠️ `finishBroadcastWithError` 是扩展**唯一**能终止广播的入口,而且必须 + /// 传一个 NSError;系统会把 localizedDescription 弹给用户看。所以这里给的 + /// 不是错误信息而是一句正常的说明,否则用户会看到一个莫名其妙的报错。 + private func finishBecausePeerGone() { + watchdog?.invalidate() + watchdog = nil + NSLog("[BroadcastExtension] 主 App 已收工,结束广播") + let reason = NSError( + domain: "com.yimai.eaimar.broadcast", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "多媒体翻译已结束,屏幕直播已自动关闭"] + ) + DispatchQueue.main.async { [weak self] in + self?.finishBroadcastWithError(reason) + } } override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, diff --git a/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart index 779751c5..c464643a 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart @@ -4,7 +4,6 @@ import 'package:get/get.dart'; import '../../../home/controllers/calendar_controller.dart'; import '../../../home/views/widgets/calendar/calendar_card.dart'; -import '../widgets/memory_edit_sheet.dart'; import '../widgets/tab_scaffold.dart'; import '../widgets/todo_scope_list.dart'; @@ -35,13 +34,6 @@ class _TodoTabState extends State { }); } - Future _addNew() async { - final ctl = Get.isRegistered() - ? Get.find() - : null; - await MemoryEditSheet.show(defaultDate: ctl?.selectedDate.value); - } - @override Widget build(BuildContext context) { final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; @@ -50,22 +42,11 @@ class _TodoTabState extends State { slogan: 'sloganHome'.tr, titleOnRight: true, isDarkMode: isDarkMode, - child: Stack( - children: [ - _buildBody(), - // 新增入口。放 Stack 而不是 Scaffold 的 floatingActionButton: - // TabScaffold 已经是页面骨架,再套一层 Scaffold 会多出一个 AppBar 空位。 - Positioned( - right: 16.w, - bottom: 16.h, - child: FloatingActionButton( - heroTag: 'memory_add', - onPressed: _addNew, - child: const Icon(Icons.add), - ), - ), - ], - ), + // ⚠️ 这里原来有个右下角的 + 号(手动新增事项)。2026-09-06 按产品要求摘掉: + // 拾忆的内容应当由助手/会议纪要自动落进来,没人有时间手动录。 + // 卡片点开编辑仍然保留(TodoScopeList 里),那是改助手记错的时间/内容用的, + // 与「手动新建」不是一回事。 + child: _buildBody(), ); }