screenMirrorToTcp method
- ScreenRecordOptions recordingOptions = const ScreenRecordOptions(),
- String? recordFile,
- bool debug = false,
Starts mirroring the device screen over a local TCP socket, so the raw H264 stream can be consumed by an embedded player (e.g. an ffplay/ffmpeg surface rendered inside a Flutter widget) instead of an external window.
This binds a io.ServerSocket on the loopback interface and, as soon as a
client connects (the player), spawns adb shell screenrecord ... - and
pipes its raw H264 stdout into the socket. The player just has to read
from tcp://127.0.0.1:<port>.
Unlike a fixed timeout, the stream runs until you call
ScreenMirrorHandle.stop (typically wired to a "stop" button in the UI),
or the device-side screenrecord limit is reached.
If recordFile is provided, the raw H264 stream is tee'd to that file
(Annex-B .h264) for the whole session. Recording is decided up front —
not mid-stream — because screenrecord emits the codec config (SPS/PPS)
and the initial keyframe (IDR) only once at the very start; a file that
began later would be missing them and would not be decodable.
Returns a ScreenMirrorHandle exposing the chosen ScreenMirrorHandle.port and a ScreenMirrorHandle.stop method.
Example (consumer side, e.g. with ffmpeg_kit_extended_flutter):
final handle = await client.shell().screenMirrorToTcp(
recordFile: '/tmp/capture.h264', // omit to mirror without recording
);
await FFplayKit.executeAsync(
'-fflags nobuffer -flags low_delay -i tcp://127.0.0.1:${handle.port}',
);
// later, on a button tap:
await handle.stop();
// then remux to mp4: ffmpeg -r 60 -i capture.h264 -c copy capture.mp4
Implementation
Future<ScreenMirrorHandle> screenMirrorToTcp({
ScreenRecordOptions recordingOptions = const ScreenRecordOptions(),
String? recordFile,
bool debug = false,
}) async {
final adbPath = _bridge.executor.adbPath;
// `screenrecord` rejects `--verbose` when the output goes to stdout (`-`):
// "ERROR: verbose output and '-' not compatible". Since this method always
// streams to stdout, strip it regardless of the options passed in.
final recordArgs = recordingOptions.toArgs()
..removeWhere((a) => a == '--verbose');
final screenArgs = [
..._connection.arguments,
'shell',
'screenrecord',
...recordArgs,
'-',
];
await _bridge.executor.init();
final server = await io.ServerSocket.bind(
io.InternetAddress.loopbackIPv4,
0,
);
debugPrint(
'screenMirrorToTcp: listening on 127.0.0.1:${server.port} '
'(adb $adbPath ${screenArgs.join(' ')})',
);
io.Process? process;
var bytesStreamed = 0;
var stopped = false;
// Recording, if requested, is opened here — at the very start of the
// stream. That guarantees the file begins with the codec config
// (SPS/PPS) and the initial keyframe (IDR) that `screenrecord` emits only
// once up front, so the resulting `.h264` is always decodable. Recording
// mid-stream would miss them, so it is intentionally not supported.
final io.IOSink? recordSink = recordFile != null
? io.File(recordFile).openWrite()
: null;
if (recordSink != null) {
debugPrint('screenMirrorToTcp: recording to $recordFile');
}
Future<void> stop([String reason = 'stop() called']) async {
debugPrint(
'screenMirrorToTcp: stopping mirror (reason: $reason, '
'bytesStreamed=$bytesStreamed, alreadyStopped=$stopped)',
);
if (stopped) return;
stopped = true;
if (recordSink != null) {
await recordSink.flush();
await recordSink.close();
}
process?.kill();
await server.close();
}
server.listen((io.Socket client) async {
debugPrint(
'screenMirrorToTcp: client connected from '
'${client.remoteAddress.address}:${client.remotePort}',
);
// Only serve the first client; the mirror is point to point.
if (process != null) {
debugPrint(
'screenMirrorToTcp: extra client rejected (adb already running)',
);
client.destroy();
return;
}
try {
process = await io.Process.start(adbPath, screenArgs, runInShell: true);
debugPrint(
'screenMirrorToTcp: adb screenrecord started (pid ${process!.pid})',
);
} catch (e) {
debugPrint('screenMirrorToTcp: failed to start adb: $e');
client.destroy();
await stop('adb failed to start: $e');
return;
}
// Tee the raw H264 stream from adb into the socket and, when recording,
// the file. A manual listen (rather than pipe) lets the same bytes reach
// both sinks, so the recording matches exactly what is displayed.
process!.stdout.listen(
(chunk) {
if (bytesStreamed == 0) {
debugPrint(
'screenMirrorToTcp: first H264 chunk received (${chunk.length} bytes)',
);
}
bytesStreamed += chunk.length;
try {
client.add(chunk);
} catch (e) {
// Client gone (player closed) -> tear everything down.
stop('socket write failed: $e');
}
recordSink?.add(chunk);
},
onError: (Object e) => stop('adb stdout error: $e'),
onDone: () => stop('adb stdout closed (onDone)'),
cancelOnError: true,
);
// Always surface adb's stderr — it explains most "adb exits immediately"
// cases (device offline, unauthorized, screenrecord unsupported, ...).
process!.stderr.transform(io.systemEncoding.decoder).listen((s) {
final trimmed = s.trim();
if (trimmed.isNotEmpty) {
debugPrint('screenMirrorToTcp[adb stderr]: $trimmed');
}
});
// When adb exits on its own, close the socket so the player stops too.
unawaited(
process!.exitCode.then((code) {
debugPrint(
'screenMirrorToTcp: adb exited with code $code (bytesStreamed=$bytesStreamed)',
);
stop('adb exited with code $code');
}),
);
});
return ScreenMirrorHandle._(
port: server.port,
stop: stop,
isRecording: recordSink != null,
);
}