Browse Source

点结束时自动关掉屏幕直播;拾忆页去掉手动新增的 + 号

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 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
6299920145
  1. 36
      apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift
  2. 36
      apps/client/ios/BroadcastExtension/SampleHandler.swift
  3. 29
      apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart

36
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<Int32>.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 字节小端长度 + 负载。

36
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,

29
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<TodoTab> {
});
}
Future<void> _addNew() async {
final ctl = Get.isRegistered<CalendarController>()
? Get.find<CalendarController>()
: 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<TodoTab> {
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(),
);
}

Loading…
Cancel
Save