fix: clear desktop first-frame overlay after decode

This commit is contained in:
Haitao Pan 2026-06-09 15:20:45 +08:00
parent 923ec8fb76
commit 91bb9a180e
3 changed files with 74 additions and 16 deletions

View File

@ -40,6 +40,16 @@ bool desktopShouldDropInputEvent(
return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit;
}
bool desktopHasRenderedVideoFrame({
required bool hasStream,
required int rendererVideoWidth,
required int rendererVideoHeight,
required bool hasDecodedFrames,
}) {
return hasStream &&
(hasDecodedFrames || (rendererVideoWidth > 0 && rendererVideoHeight > 0));
}
String desktopSessionId() {
return 'remote-desktop-${randomIdInternal()}';
}

View File

@ -55,6 +55,7 @@ class _DesktopViewState extends State<DesktopView> {
bool _showControlPanel = true;
String _connectionState = 'disconnected';
bool _hasStream = false;
bool _hasDecodedVideoFrame = false;
bool _isFocused = false;
Size _remoteDesktopSize = const Size(1280, 720);
@ -65,10 +66,12 @@ class _DesktopViewState extends State<DesktopView> {
StreamSubscription<String>? _stateSubscription;
Timer? _firstFrameStatsTimer;
bool get _hasVideoFrame =>
_hasStream &&
_localRenderer.videoWidth > 0 &&
_localRenderer.videoHeight > 0;
bool get _hasVideoFrame => desktopHasRenderedVideoFrame(
hasStream: _hasStream,
rendererVideoWidth: _localRenderer.videoWidth,
rendererVideoHeight: _localRenderer.videoHeight,
hasDecodedFrames: _hasDecodedVideoFrame,
);
@override
void initState() {
@ -91,6 +94,7 @@ class _DesktopViewState extends State<DesktopView> {
setState(() {
_localRenderer.srcObject = stream;
_hasStream = true;
_hasDecodedVideoFrame = false;
});
_startFirstFrameDiagnostics();
}
@ -103,6 +107,7 @@ class _DesktopViewState extends State<DesktopView> {
if (_connectionState == 'disconnected' ||
_connectionState == 'failed') {
_hasStream = false;
_hasDecodedVideoFrame = false;
_localRenderer.srcObject = null;
_stopFirstFrameDiagnostics();
}
@ -114,6 +119,9 @@ class _DesktopViewState extends State<DesktopView> {
Future<void> _initRenderer() async {
await _localRenderer.initialize();
_localRenderer.onResize = () {
if (_localRenderer.videoWidth > 0 && _localRenderer.videoHeight > 0) {
_hasDecodedVideoFrame = true;
}
_stopFirstFrameDiagnostics();
if (mounted) {
setState(() {});
@ -123,25 +131,35 @@ class _DesktopViewState extends State<DesktopView> {
void _startFirstFrameDiagnostics() {
_firstFrameStatsTimer?.cancel();
_firstFrameStatsTimer = Timer.periodic(const Duration(seconds: 5), (_) {
unawaited(_collectFirstFrameStats());
_firstFrameStatsTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (!_hasStream || _hasVideoFrame || !mounted) {
_stopFirstFrameDiagnostics();
return;
}
unawaited(() async {
try {
final stats = await _client.collectVideoStats();
if (stats == null) {
return;
}
debugPrint('Remote desktop waiting for first frame: $stats');
} catch (error) {
debugPrint('Remote desktop stats failed: $error');
}
}());
unawaited(_collectFirstFrameStats());
});
}
Future<void> _collectFirstFrameStats() async {
try {
final stats = await _client.collectVideoStats();
if (stats == null || !mounted || !_hasStream) {
return;
}
if (stats.hasDecodedFrames) {
setState(() {
_hasDecodedVideoFrame = true;
});
_stopFirstFrameDiagnostics();
return;
}
debugPrint('Remote desktop waiting for first frame: $stats');
} catch (error) {
debugPrint('Remote desktop stats failed: $error');
}
}
void _stopFirstFrameDiagnostics() {
_firstFrameStatsTimer?.cancel();
_firstFrameStatsTimer = null;

View File

@ -177,6 +177,36 @@ void main() {
);
});
test('treats decoded video stats as a rendered first frame', () {
expect(
desktopHasRenderedVideoFrame(
hasStream: true,
rendererVideoWidth: 0,
rendererVideoHeight: 0,
hasDecodedFrames: true,
),
isTrue,
);
expect(
desktopHasRenderedVideoFrame(
hasStream: true,
rendererVideoWidth: 1280,
rendererVideoHeight: 720,
hasDecodedFrames: false,
),
isTrue,
);
expect(
desktopHasRenderedVideoFrame(
hasStream: false,
rendererVideoWidth: 1280,
rendererVideoHeight: 720,
hasDecodedFrames: true,
),
isFalse,
);
});
test('uses bridge-provided remote stream when present', () async {
var fallbackCreated = false;
final providedStream = FakeMediaStream('provided-stream');