screenMirrorToTcp method

Future<ScreenMirrorHandle> screenMirrorToTcp({
  1. ScreenRecordOptions recordingOptions = const ScreenRecordOptions(),
  2. String? recordFile,
  3. 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,
  );
}