feat: 增加信令自动重连保活与控制端视频录制
- 信令客户端增加心跳保活与指数退避自动重连机制 - 服务端优化传输异常日志级别,客户端断开不再报错 - 被控端信令断开时保持服务运行并自动重连 - Flutter 控制端增加远程视频录制功能,兼容标准 WebRTC 模式切换 - 各端连接按钮增加“连接中”状态防重复点击
This commit is contained in:
@@ -267,12 +267,19 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
|
||||
// ---- ScreenCaptureService.ServiceStateListener 实现 ----
|
||||
|
||||
@Override
|
||||
public void onSignalConnected() {
|
||||
runOnUiThread(() -> {
|
||||
// 信令服务器连接/重连成功:恢复运行状态显示。
|
||||
updateUI(true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSignalDisconnected() {
|
||||
runOnUiThread(() -> {
|
||||
// 信令服务器断开:刷新为已停止状态,并解绑/停止服务释放资源。
|
||||
updateUI(false);
|
||||
stopScreenSharing();
|
||||
// 信令服务器短暂断开:保持服务运行,WebSocketClient 会自动重连。
|
||||
binding.tvStatus.setText("状态: 信令断开,正在重连...");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -544,7 +544,12 @@ public class ScreenCaptureService extends Service {
|
||||
*/
|
||||
public interface ServiceStateListener {
|
||||
/**
|
||||
* 信令服务器断开(WebSocket 断开)。
|
||||
* 信令服务器已连接(首次连接或自动重连成功)。
|
||||
*/
|
||||
void onSignalConnected();
|
||||
|
||||
/**
|
||||
* 信令服务器断开(WebSocket 断开,客户端会自动重连)。
|
||||
*/
|
||||
void onSignalDisconnected();
|
||||
|
||||
@@ -616,6 +621,11 @@ public class ScreenCaptureService extends Service {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
Log.i(TAG, "Connected to signal server");
|
||||
isShuttingDown = false; // 重连成功后重新武装
|
||||
updateNotification("远程控制服务正在运行");
|
||||
if (stateListener != null) {
|
||||
stateListener.onSignalConnected();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -837,9 +847,10 @@ public class ScreenCaptureService extends Service {
|
||||
*/
|
||||
private void onSignalServerDisconnected() {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
Log.i(TAG, "Signal server disconnected, stopping screen sharing");
|
||||
showToast(getString(R.string.signal_disconnected_message));
|
||||
Log.i(TAG, "Signal server disconnected, will auto-reconnect");
|
||||
// 不再直接停止服务:WebSocketClient 会自动重连,服务保持运行。
|
||||
showToast(getString(R.string.signal_reconnecting_message));
|
||||
updateNotification("信令断开,正在重连...");
|
||||
if (stateListener != null) {
|
||||
stateListener.onSignalDisconnected();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import android.util.Log;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
@@ -17,14 +22,29 @@ public class WebSocketClient {
|
||||
|
||||
private static final String TAG = "WebSocketClient";
|
||||
|
||||
// 应用层心跳:定期发送 PING,避免经过反向代理(nginx 等)空闲超时导致连接被断开。
|
||||
private static final long HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
// OkHttp 协议层 ping:保持 TCP 通道活跃,与心跳互补。
|
||||
private static final long PING_INTERVAL_MS = 20_000;
|
||||
// 自动重连指数退避参数。
|
||||
private static final long RECONNECT_BASE_DELAY_MS = 2_000;
|
||||
private static final long RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
private static final int RECONNECT_BACKOFF_STEPS = 5;
|
||||
|
||||
private static final String PING_PAYLOAD = "{\"type\":\"PING\"}";
|
||||
|
||||
private final String serverUrl;
|
||||
private final String deviceId;
|
||||
private final Gson gson = new GsonBuilder().disableHtmlEscaping().create();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private OkHttpClient client;
|
||||
private WebSocket webSocket;
|
||||
private volatile WebSocket webSocket;
|
||||
private SignalListener listener;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
private int reconnectAttempts = 0;
|
||||
private final AtomicBoolean manuallyClosed = new AtomicBoolean(false);
|
||||
|
||||
public interface SignalListener {
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
@@ -42,14 +62,42 @@ public class WebSocketClient {
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
client = new OkHttpClient.Builder().build();
|
||||
Request request = new Request.Builder().url(serverUrl).build();
|
||||
manuallyClosed.set(false);
|
||||
reconnectAttempts = 0;
|
||||
ensureScheduler();
|
||||
doConnect();
|
||||
}
|
||||
|
||||
webSocket = client.newWebSocket(request, new WebSocketListener() {
|
||||
private void ensureScheduler() {
|
||||
if (scheduler == null || scheduler.isShutdown()) {
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "WebSocketClient-Scheduler");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void doConnect() {
|
||||
if (manuallyClosed.get()) {
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, "Connecting to " + serverUrl);
|
||||
client = new OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // 不依赖读超时,由心跳/ping 保活
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.pingInterval(PING_INTERVAL_MS, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder().url(serverUrl).build();
|
||||
client.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket ws, Response response) {
|
||||
webSocket = ws;
|
||||
reconnectAttempts = 0;
|
||||
Log.i(TAG, "WebSocket connected");
|
||||
// 发送注册消息
|
||||
startHeartbeat();
|
||||
registerDevice();
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onConnected();
|
||||
@@ -72,35 +120,95 @@ public class WebSocketClient {
|
||||
@Override
|
||||
public void onClosed(WebSocket ws, int code, String reason) {
|
||||
Log.i(TAG, "WebSocket closed: " + reason);
|
||||
webSocket = null;
|
||||
stopHeartbeat();
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onDisconnected();
|
||||
});
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket ws, Throwable t, Response response) {
|
||||
Log.e(TAG, "WebSocket error: " + t.getMessage(), t);
|
||||
webSocket = null;
|
||||
stopHeartbeat();
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onError(t.getMessage());
|
||||
});
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void startHeartbeat() {
|
||||
if (scheduler == null || scheduler.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
WebSocket ws = webSocket;
|
||||
if (ws != null && !manuallyClosed.get()) {
|
||||
try {
|
||||
ws.send(PING_PAYLOAD);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Heartbeat send failed", e);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void stopHeartbeat() {
|
||||
// 心跳任务随 scheduler 关闭或 webSocket 置空而停止,此处无需额外处理。
|
||||
}
|
||||
|
||||
private void scheduleReconnect() {
|
||||
if (manuallyClosed.get()) {
|
||||
return;
|
||||
}
|
||||
if (scheduler == null || scheduler.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
long delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * (1L << Math.min(reconnectAttempts, RECONNECT_BACKOFF_STEPS)),
|
||||
RECONNECT_MAX_DELAY_MS);
|
||||
reconnectAttempts++;
|
||||
Log.i(TAG, "Scheduling reconnect in " + delay + "ms (attempt " + reconnectAttempts + ")");
|
||||
scheduler.schedule(() -> {
|
||||
if (manuallyClosed.get()) {
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, "Attempting reconnect...");
|
||||
doConnect();
|
||||
}, delay, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void sendMessage(SignalMessage message) {
|
||||
if (webSocket != null) {
|
||||
WebSocket ws = webSocket;
|
||||
if (ws != null) {
|
||||
String json = gson.toJson(message);
|
||||
Log.d(TAG, "Sending: " + json);
|
||||
webSocket.send(json);
|
||||
ws.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "Disconnecting");
|
||||
manuallyClosed.set(true);
|
||||
WebSocket ws = webSocket;
|
||||
webSocket = null;
|
||||
stopHeartbeat();
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
if (ws != null) {
|
||||
try {
|
||||
ws.close(1000, "Disconnecting");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (client != null) {
|
||||
client.dispatcher().executorService().shutdown();
|
||||
client = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="connection_accept">接受</string>
|
||||
<string name="connection_reject">拒绝</string>
|
||||
<string name="signal_disconnected_message">与信令服务器断开,屏幕共享已停止</string>
|
||||
<string name="signal_reconnecting_message">与信令服务器断开,正在尝试重连…</string>
|
||||
<string name="controller_disconnected_message">控制端(%s)已断开远程控制</string>
|
||||
<string name="accessibility_service_description">用于远程控制时模拟键盘与触摸输入。开启后,控制端可通过本服务向本机发送返回、主页、音量等系统按键及触摸手势。</string>
|
||||
<string name="accessibility_hint">未使用系统签名:如需在本机模拟键盘/触摸,请在系统设置中开启本应用的无障碍服务。</string>
|
||||
|
||||
@@ -31,8 +31,10 @@ function onConnectDevice() {
|
||||
<input class="input" v-model="store.deviceId" placeholder="例如 web-controller-001" />
|
||||
</div>
|
||||
|
||||
<button class="btn" v-if="!store.signalingConnected" @click="connectSignaling">连接信令服务器</button>
|
||||
<button class="btn danger" v-else @click="disconnectSignaling">断开信令</button>
|
||||
<button class="btn danger" v-if="store.signalingConnected" @click="disconnectSignaling">断开信令服务器</button>
|
||||
<button class="btn" v-else :disabled="store.signalingConnecting" @click="connectSignaling">
|
||||
{{ store.signalingConnecting ? '正在连接...' : '连接信令服务器' }}
|
||||
</button>
|
||||
|
||||
<div class="section-title">被控端设备 (CONTROLLED)</div>
|
||||
<button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="!store.signalingConnected">
|
||||
|
||||
@@ -19,3 +19,33 @@ import { store } from '../store/controllerStore';
|
||||
<span>目标: <b>{{ store.targetDeviceId || '-' }}</b></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stats-bar span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 每个统计项固定宽度,避免文字多少导致整体跳动 */
|
||||
.stats-bar span:nth-child(1) { width: 140px; } /* 分辨率 */
|
||||
.stats-bar span:nth-child(2) { width: 90px; } /* 帧率 */
|
||||
.stats-bar span:nth-child(3) { width: 110px; } /* 编码 */
|
||||
.stats-bar span:nth-child(4) { width: 100px; } /* 延迟 */
|
||||
.stats-bar span:nth-child(5) { width: 100px; } /* 抖动 */
|
||||
.stats-bar span:nth-child(6) { width: 100px; } /* 丢帧 */
|
||||
.stats-bar span:nth-child(7) { width: 120px; } /* 解码耗时 */
|
||||
.stats-bar span:nth-child(8) { width: 120px; } /* 上传 */
|
||||
.stats-bar span:nth-child(9) { width: 120px; } /* 下载 */
|
||||
.stats-bar span:nth-child(10) { width: 100px; } /* 丢包 */
|
||||
.stats-bar span:nth-child(11) { width: 120px; } /* 时长 */
|
||||
.stats-bar span:nth-child(12) { width: 140px; } /* 控制通道 */
|
||||
.stats-bar span:nth-child(13) { width: 160px; } /* 目标 */
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,7 @@ export const store = reactive({
|
||||
|
||||
protoReady: false,
|
||||
signalingConnected: false,
|
||||
signalingConnecting: false,
|
||||
registered: false,
|
||||
rtcConnected: false,
|
||||
dataChannelOpen: false,
|
||||
@@ -60,20 +61,26 @@ export function connectSignaling() {
|
||||
const deviceId = store.deviceId.trim();
|
||||
if (!deviceId) { store.error = '请填写本机设备 ID'; return; }
|
||||
|
||||
store.signalingConnecting = true;
|
||||
signaling = new SignalingClient({
|
||||
serverUrl: store.serverUrl.trim(),
|
||||
deviceId,
|
||||
onConnected: () => {
|
||||
store.signalingConnected = true;
|
||||
store.signalingConnecting = false;
|
||||
store.statusText = '已连接信令服务器';
|
||||
refreshDevices();
|
||||
},
|
||||
onDisconnected: () => {
|
||||
store.signalingConnected = false;
|
||||
store.signalingConnecting = false;
|
||||
store.registered = false;
|
||||
store.statusText = '已断开信令连接';
|
||||
},
|
||||
onError: (e) => { store.error = '信令错误: ' + e; },
|
||||
onError: (e) => {
|
||||
store.signalingConnecting = false;
|
||||
store.error = '信令错误: ' + e;
|
||||
},
|
||||
onMessage: handleSignalMessage,
|
||||
});
|
||||
signaling.connect();
|
||||
@@ -172,6 +179,7 @@ export function disconnectSignaling() {
|
||||
disconnectDevice();
|
||||
if (signaling) { signaling.disconnect(); signaling = null; }
|
||||
store.signalingConnected = false;
|
||||
store.signalingConnecting = false;
|
||||
store.registered = false;
|
||||
store.controlledDevices = [];
|
||||
}
|
||||
|
||||
@@ -123,11 +123,24 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
logger.error("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
||||
// EOFException / 连接被对端重置 属于正常网络事件(客户端网络中断、代理空闲超时等),
|
||||
// 并非服务器错误,降级为 DEBUG 记录,避免误导。
|
||||
boolean benign = exception instanceof java.io.EOFException
|
||||
|| (exception.getMessage() != null
|
||||
&& (exception.getMessage().contains("Connection reset")
|
||||
|| exception.getMessage().contains("Broken pipe")
|
||||
|| exception.getMessage().contains("An established connection")));
|
||||
if (benign) {
|
||||
logger.debug("Transport closed (client disconnected) on session {}: {}",
|
||||
session.getId(), exception.getMessage());
|
||||
} else {
|
||||
logger.warn("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
||||
}
|
||||
sessionManager.unregisterSession(session);
|
||||
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||
// 连接已不可用时无需再以 SERVER_ERROR(1011) 关闭;仅在仍打开时温和关闭。
|
||||
if (session.isOpen()) {
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
session.close(CloseStatus.GOING_AWAY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
<!-- 远程控制需要通过网络(含 ws:// 明文)连接信令服务器 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- 录制文件保存位置(Android Q 及以上写入 app 专属目录免授权;
|
||||
低版本如需兼容可保留以下声明,运行时并不强制索取) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<application
|
||||
android:label="webrtc_controller_flutter"
|
||||
|
||||
@@ -89,6 +89,7 @@ class RemoteController {
|
||||
};
|
||||
_signaling.onError = (error) {
|
||||
onStatusChanged?.call('状态: 连接错误 - $error');
|
||||
onConnectionFailed?.call(error);
|
||||
};
|
||||
_signaling.connect();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'controller/remote_controller.dart';
|
||||
import 'utils/control_commands.dart';
|
||||
import 'utils/device_utils.dart';
|
||||
import 'webrtc/self_codec_decoder.dart';
|
||||
import 'webrtc/video_recorder.dart';
|
||||
import 'widgets/remote_touch_view.dart';
|
||||
|
||||
void main() {
|
||||
@@ -52,6 +53,8 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
RTCVideoRenderer? _renderer;
|
||||
|
||||
bool _connected = false;
|
||||
/// 是否正在连接信令服务器 / 建立 WebRTC(连接过程中禁用“连接”按钮)。
|
||||
bool _connecting = false;
|
||||
String _status = '状态: 已停止';
|
||||
String _stats = '';
|
||||
double _videoAspect = 16 / 9;
|
||||
@@ -62,6 +65,13 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
bool _selfCodecReady = false;
|
||||
bool _selfCodecSupported = true;
|
||||
|
||||
/// 远程视频录制器(基于 flutter_webrtc 的 MediaRecorder)。
|
||||
final VideoRecorder _videoRecorder = VideoRecorder();
|
||||
bool _recording = false;
|
||||
String _recordStatus = '';
|
||||
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式、待就绪后再开始录制。
|
||||
bool _pendingRecordStart = false;
|
||||
|
||||
/// 分辨率预设:width 为长边像素;0 表示被控端原生分辨率;
|
||||
/// height 传 0(由被控端按屏幕宽高比计算),fps 传 0(沿用当前帧率)。
|
||||
int _selectedResolution = 0;
|
||||
@@ -88,6 +98,7 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoRecorder.dispose();
|
||||
_controller?.disconnect();
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
@@ -266,6 +277,7 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _connecting = true);
|
||||
_setStatus('状态: 正在连接信令服务器...');
|
||||
|
||||
_controller = RemoteController(
|
||||
@@ -279,11 +291,19 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_controller!.onConnectionEstablished = () {
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_connecting = false;
|
||||
_status = '状态: 已连接 - 远程控制中';
|
||||
});
|
||||
};
|
||||
_controller!.onConnectionFailed = (error) {
|
||||
if (mounted) setState(() => _connecting = false);
|
||||
_showAlert('连接失败:$error');
|
||||
};
|
||||
_controller!.onDisconnected = () {
|
||||
setState(() => _connected = false);
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
});
|
||||
_setStatus('状态: 远端已断开');
|
||||
};
|
||||
_controller!.onIceDisconnected = (message) {
|
||||
@@ -298,6 +318,10 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_controller!.onRemoteStream = (renderer) {
|
||||
setState(() => _renderer = renderer);
|
||||
renderer.addListener(_onRendererUpdate);
|
||||
if (_pendingRecordStart) {
|
||||
_pendingRecordStart = false;
|
||||
_startRecording();
|
||||
}
|
||||
};
|
||||
_controller!.onStats = (stats) => setState(() => _stats = stats);
|
||||
_controller!.onSelfCodecReady = (textureId) {
|
||||
@@ -318,6 +342,10 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
};
|
||||
_controller!.onStreamModeReport = (mode) {
|
||||
if (mounted) setState(() => _streamMode = mode);
|
||||
if (mode == SelfCodecDecoder.streamModeWebRtc && _pendingRecordStart) {
|
||||
_pendingRecordStart = false;
|
||||
_startRecording();
|
||||
}
|
||||
};
|
||||
_controller!.onResolutionReported = (w, h) {
|
||||
if (w > 0 && h > 0 && mounted) {
|
||||
@@ -363,13 +391,19 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
/// 目标被控端不在线:提示用户并复位到连接设置面板。
|
||||
Future<void> _onTargetOffline(String message) async {
|
||||
_showAlert(message);
|
||||
setState(() => _connected = false);
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
|
||||
Future<void> _onConnectionRejected(String message) async {
|
||||
_showAlert(message);
|
||||
setState(() => _connected = false);
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
|
||||
@@ -426,13 +460,70 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_controller?.sendStreamMode(next);
|
||||
}
|
||||
|
||||
/// 切换远程视频录制:开始 / 停止。
|
||||
Future<void> _toggleRecord() async {
|
||||
if (_recording) {
|
||||
await _stopRecording();
|
||||
return;
|
||||
}
|
||||
if (_renderer == null) {
|
||||
_showAlert('尚未连接或没有视频画面,无法录制');
|
||||
return;
|
||||
}
|
||||
if (_streamMode == SelfCodecDecoder.streamModeSelfCodec) {
|
||||
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
|
||||
_pendingRecordStart = true;
|
||||
setState(() => _recordStatus = '正在切回标准模式以开始录制...');
|
||||
_controller?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
|
||||
return;
|
||||
}
|
||||
await _startRecording();
|
||||
}
|
||||
|
||||
/// 开始录制当前远端视频轨道。
|
||||
Future<void> _startRecording() async {
|
||||
if (_recording || !mounted) return;
|
||||
final stream = _renderer?.srcObject;
|
||||
if (stream == null) {
|
||||
_showAlert('尚未接收到视频画面,无法录制');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final ok = await _videoRecorder.start(stream);
|
||||
if (ok && mounted) {
|
||||
setState(() {
|
||||
_recording = true;
|
||||
_recordStatus = '录制中...';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _recordStatus = '');
|
||||
_showAlert('开始录制失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止录制并提示保存位置。
|
||||
Future<void> _stopRecording() async {
|
||||
final path = await _videoRecorder.stop();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recording = false;
|
||||
_recordStatus = path != null ? '已保存:$path' : '录制已停止';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
await _videoRecorder.dispose();
|
||||
await _controller?.disconnect();
|
||||
_controller = null;
|
||||
_renderer?.removeListener(_onRendererUpdate);
|
||||
_lastResizedAspect = 0;
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
_renderer = null;
|
||||
_status = '状态: 已停止';
|
||||
_stats = '';
|
||||
@@ -440,6 +531,9 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_selfCodecTextureId = null;
|
||||
_selfCodecReady = false;
|
||||
_selfCodecSupported = true;
|
||||
_recording = false;
|
||||
_recordStatus = '';
|
||||
_pendingRecordStart = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -501,8 +595,8 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: _showAuthDialog,
|
||||
child: const Text('连接被控设备'),
|
||||
onPressed: _connecting ? null : _showAuthDialog,
|
||||
child: Text(_connecting ? '正在连接...' : '连接被控设备'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -569,6 +663,33 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 远程视频录制开关
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: _toggleRecord,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_recording
|
||||
? CupertinoIcons.stop_circle
|
||||
: CupertinoIcons.video_camera,
|
||||
color: _recording
|
||||
? CupertinoColors.destructiveRed
|
||||
: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_recording ? '停止' : '录制',
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
@@ -668,6 +789,14 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_stats,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
if (_recordStatus.isNotEmpty)
|
||||
Text(
|
||||
_recordStatus,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.systemOrange,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
95
webrtc_controller_flutter/lib/webrtc/video_recorder.dart
Normal file
95
webrtc_controller_flutter/lib/webrtc/video_recorder.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 远程视频录制器:基于 flutter_webrtc 的 [MediaRecorder],
|
||||
/// 把远端视频轨道(MediaStreamTrack)直接封装为 MP4 保存到本地。
|
||||
///
|
||||
/// 跨平台(Android / iOS)由 flutter_webrtc 内部完成 H.264 编码与封装,
|
||||
/// 无需自行触碰原生 VideoSink。
|
||||
///
|
||||
/// 录制文件保存位置:
|
||||
/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
|
||||
/// - iOS:<App>/Documents/WebRTCRecordings/
|
||||
class VideoRecorder {
|
||||
MediaRecorder? _recorder;
|
||||
bool _recording = false;
|
||||
String? _currentPath;
|
||||
|
||||
/// 是否正在录制。
|
||||
bool get isRecording => _recording;
|
||||
|
||||
/// 是否为空(从未真正开始)。
|
||||
String? get currentPath => _currentPath;
|
||||
|
||||
/// 开始录制指定媒体流中的视频轨道。
|
||||
///
|
||||
/// 返回 true 表示成功开始;空流 / 无视频轨道会抛异常。
|
||||
Future<bool> start(MediaStream stream) async {
|
||||
if (_recording) return false;
|
||||
final videoTracks = stream.getVideoTracks();
|
||||
if (videoTracks.isEmpty) {
|
||||
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
||||
}
|
||||
final path = await _buildOutputPath();
|
||||
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
|
||||
await _recorder!.start(path, videoTrack: videoTracks.first);
|
||||
_currentPath = path;
|
||||
_recording = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 停止录制,返回最终保存的文件路径;未开始录制返回 null。
|
||||
Future<String?> stop() async {
|
||||
if (!_recording || _recorder == null) return null;
|
||||
try {
|
||||
await _recorder!.stop();
|
||||
} catch (e) {
|
||||
// 停止失败时仍清理状态,避免界面卡在“录制中”
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] stop error: $e');
|
||||
} finally {
|
||||
_recorder = null;
|
||||
_recording = false;
|
||||
}
|
||||
final p = _currentPath;
|
||||
_currentPath = null;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// 释放资源(断开连接 / 页面销毁时调用)。
|
||||
Future<void> dispose() async {
|
||||
if (_recording && _recorder != null) {
|
||||
try {
|
||||
await _recorder!.stop();
|
||||
} catch (_) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
_recorder = null;
|
||||
_recording = false;
|
||||
_currentPath = null;
|
||||
}
|
||||
|
||||
static String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
static Future<Directory> _getRecordingDir() async {
|
||||
final base = Platform.isAndroid
|
||||
? (await getExternalStorageDirectory())!
|
||||
: await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${base.path}/WebRTCRecordings');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
static Future<String> _buildOutputPath() async {
|
||||
final dir = await _getRecordingDir();
|
||||
final now = DateTime.now();
|
||||
final name = 'rec_${now.year}${_pad(now.month)}${_pad(now.day)}_'
|
||||
'${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}.mp4';
|
||||
return '${dir.path}/$name';
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
|
||||
@@ -38,6 +38,9 @@ dependencies:
|
||||
# 跨平台 WebRTC(同时支持 Android 与 iOS)
|
||||
flutter_webrtc: ^1.5.2
|
||||
|
||||
# 录制文件保存路径(app 专属目录,免运行时存储权限)
|
||||
path_provider: ^2.1.5
|
||||
|
||||
# WebSocket 信令通信
|
||||
web_socket_channel: ^3.0.3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user