flutter_android_bridge 0.3.1 copy "flutter_android_bridge: ^0.3.1" to clipboard
flutter_android_bridge: ^0.3.1 copied to clipboard

Flutter Android Bridge is a Flutter package that provides a bridge to interact with Android's native functionalities. This package allows Flutter applications to communicate with Android's package man [...]

example/lib/main.dart

import 'dart:io' show Platform;

import 'package:ffmpeg_kit_extended_flutter/ffmpeg_kit_extended_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_android_bridge/library.dart';
// `library.dart` and `material.dart` both export a `Size`. `screenrecord`
// sizes are the bridge's int-pixel one, so name it under a prefix instead of
// leaving the plain `Size` reference ambiguous.
import 'package:flutter_android_bridge/flutter_android_types.dart'
    as bridge
    show Size;

import 'android_mirror_view.dart';

void main() async {
  await FFmpegKitExtended.initialize();
  runApp(const MirrorApp());
}

class MirrorApp extends StatelessWidget {
  const MirrorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Android Screen Mirror',
      theme: ThemeData.dark(useMaterial3: true),
      home: const MirrorPage(),
    );
  }
}

class MirrorPage extends StatefulWidget {
  const MirrorPage({super.key});

  @override
  State<MirrorPage> createState() => _MirrorPageState();
}

class _MirrorPageState extends State<MirrorPage> {
  final _adbPathController = TextEditingController(
    text:
        '${Platform.environment['HOME']}/Library/Android/sdk/platform-tools/adb',
  );
  final _addressController = TextEditingController(text: '192.168.1.112:5555');
  final _mirrorKey = GlobalKey<AndroidMirrorViewState>();

  FlutterAndroidClient? _client;
  bool _connecting = false;
  bool _running = false;
  bool _recordEnabled = false;
  bool _stoppingManually = false;
  String? _status;

  /// Scales of the device's *own* resolution rather than fixed sizes.
  ///
  /// `screenrecord` fits the display inside whatever `--size` frame it is
  /// given and keeps the display's aspect ratio while doing so, so asking a
  /// portrait phone for `1280x720` returns a 16:9 video that is mostly black
  /// bars. Scaling the device resolution keeps the frame the same shape as the
  /// screen. `null` = don't pass `--size` at all.
  ///
  /// Lowering the scale, like lowering the bitrate, is the usual fix for a
  /// device answering `unable to start codec (err=-12)`: its AVC encoder
  /// cannot serve the requested resolution/bitrate.
  static const _scales = <String, double?>{
    'Device default': null,
    '100%': 1.0,
    '75%': 0.75,
    '50%': 0.5,
    '33%': 1 / 3,
  };

  static const _bitrates = <String, int>{
    '8 Mbps': 8000000,
    '4 Mbps': 4000000,
    '2 Mbps': 2000000,
    '1 Mbps': 1000000,
    '500 kbps': 500000,
  };

  String _scale = 'Device default';
  String _bitrate = '4 Mbps';

  /// Display resolution, read once on connect. `null` when `wm size` could not
  /// be read, in which case only 'Device default' is offered.
  bridge.Size? _deviceSize;

  /// The `--size` frame for [scale], `null` when `--size` should be omitted.
  bridge.Size? _sizeFor(String scale) {
    final factor = _scales[scale];
    final device = _deviceSize;
    if (factor == null || device == null) return null;
    // Snapped to multiples of 16, the AVC macroblock size encoders are
    // happiest with. That costs a couple of pixels of aspect ratio at most.
    int snap(int edge) {
      final blocks = (edge * factor / 16).round();
      return (blocks < 1 ? 1 : blocks) * 16;
    }

    return bridge.Size(snap(device.width), snap(device.height));
  }

  /// `'50%'` -> `'50% (544x1200)'` once the device resolution is known.
  String _scaleLabel(String scale) {
    final size = _sizeFor(scale);
    return size == null ? scale : '$scale (${size.width}x${size.height})';
  }

  /// Reads the display resolution, e.g. `Physical size: 1080x2400`. An
  /// `Override size:` line wins when present: that's what the display really
  /// renders at (after `wm size 720x1600` and the like).
  ///
  /// It is the *natural* orientation, which is what `--size` wants. A device
  /// held in landscape therefore still letterboxes inside a scaled portrait
  /// frame — pick 'Device default' for those.
  Future<bridge.Size?> _readDeviceSize(FlutterAndroidClient client) async {
    try {
      final result = await client.shell().exec(['wm', 'size']);
      final matches = RegExp(
        r'^(?:Physical|Override) size:\s*(\d+)x(\d+)',
        multiLine: true,
      ).allMatches(result.stdout.toString());
      if (matches.isEmpty) return null;
      final match = matches.last;
      return bridge.Size(
        int.parse(match.group(1)!),
        int.parse(match.group(2)!),
      );
    } catch (e) {
      // Not fatal: without it only 'Device default' is offered.
      debugPrint('MirrorPage: could not read display size: $e');
      return null;
    }
  }

  ScreenRecordOptions get _recordingOptions =>
      ScreenRecordOptions(bitrate: _bitrates[_bitrate], size: _sizeFor(_scale));

  String get _recordPath =>
      '${Platform.environment['HOME']}/android_mirror_capture.h264';

  Future<void> _connect() async {
    setState(() {
      _connecting = true;
      _status = null;
    });
    try {
      final adb = FlutterAndroidBridge(_adbPathController.text.trim());
      final client = adb.newClient(_addressController.text.trim());
      final connected = await client.connect();
      if (!connected) {
        throw StateError('Could not connect to ${_addressController.text}');
      }
      // The scale presets are relative to the device resolution, so read it
      // here — while nothing is streaming yet.
      final deviceSize = await _readDeviceSize(client);
      if (!mounted) return;
      setState(() {
        _client = client;
        _deviceSize = deviceSize;
        // Keep the dropdown selection valid: without a known resolution
        // 'Device default' is the only preset left.
        if (deviceSize == null) _scale = 'Device default';
        _status = deviceSize == null
            ? 'Connected'
            : 'Connected (${deviceSize.width}x${deviceSize.height})';
      });
    } catch (e) {
      setState(() => _status = 'Connect failed: $e');
    } finally {
      if (mounted) setState(() => _connecting = false);
    }
  }

  Future<void> _start() async {
    _stoppingManually = false;
    final recordFile = _recordEnabled ? _recordPath : null;
    await _mirrorKey.currentState?.start(recordFile: recordFile);
    if (mounted) {
      setState(() {
        _running = true;
        _status = recordFile != null ? 'Recording to $recordFile' : 'Mirroring';
      });
    }
  }

  Future<void> _stop() async {
    // Marks the teardown as user-initiated so `onEnded` doesn't overwrite the
    // status below with a generic "Stopped".
    _stoppingManually = true;
    await _mirrorKey.currentState?.stop();
    if (mounted) {
      setState(() {
        _running = false;
        _status = _recordEnabled
            ? 'Saved recording to $_recordPath'
            : 'Stopped';
      });
    }
  }

  @override
  void dispose() {
    _adbPathController.dispose();
    _addressController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final client = _client;
    return Scaffold(
      appBar: AppBar(title: const Text('Android Screen Mirror')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _adbPathController,
              decoration: const InputDecoration(
                labelText: 'adb path',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _addressController,
                    decoration: const InputDecoration(
                      labelText: 'device address (ip:port or serial)',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 12),
                FilledButton(
                  onPressed: _connecting ? null : _connect,
                  child: _connecting
                      ? const SizedBox(
                          width: 18,
                          height: 18,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        )
                      : const Text('Connect'),
                ),
              ],
            ),
            const SizedBox(height: 12),
            // Encoder settings. Lower them when the device reports
            // "unable to start codec": its AVC encoder cannot handle the
            // requested resolution/bitrate. Locked while running.
            Row(
              children: [
                Expanded(
                  child: DropdownButtonFormField<String>(
                    initialValue: _scale,
                    decoration: const InputDecoration(
                      labelText: 'resolution',
                      border: OutlineInputBorder(),
                      isDense: true,
                    ),
                    items: [
                      for (final scale in _scales.keys)
                        // Scales need the device resolution to resolve to a
                        // frame; before Connect only the default applies.
                        if (_scales[scale] == null || _deviceSize != null)
                          DropdownMenuItem(
                            value: scale,
                            child: Text(_scaleLabel(scale)),
                          ),
                    ],
                    onChanged: _running
                        ? null
                        : (v) => setState(() => _scale = v!),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: DropdownButtonFormField<String>(
                    initialValue: _bitrate,
                    decoration: const InputDecoration(
                      labelText: 'bitrate',
                      border: OutlineInputBorder(),
                      isDense: true,
                    ),
                    items: _bitrates.keys
                        .map(
                          (k) => DropdownMenuItem(value: k, child: Text(k)),
                        )
                        .toList(),
                    onChanged: _running
                        ? null
                        : (v) => setState(() => _bitrate = v!),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                FilledButton.icon(
                  onPressed: client != null && !_running ? _start : null,
                  icon: const Icon(Icons.play_arrow),
                  label: const Text('Start mirror'),
                ),
                const SizedBox(width: 12),
                OutlinedButton.icon(
                  onPressed: _running ? _stop : null,
                  icon: const Icon(Icons.stop),
                  label: const Text('Stop'),
                ),
                const SizedBox(width: 12),
                // Recording is chosen up front (before Start), so the file
                // captures the initial keyframe and stays decodable.
                Tooltip(
                  message: 'Record the session to $_recordPath',
                  child: FilterChip(
                    avatar: Icon(
                      _recordEnabled
                          ? Icons.fiber_manual_record
                          : Icons.radio_button_unchecked,
                      color: _recordEnabled ? Colors.red : null,
                    ),
                    label: const Text('Record'),
                    selected: _recordEnabled,
                    // Locked while running: can't toggle mid-stream.
                    onSelected: _running
                        ? null
                        : (v) => setState(() => _recordEnabled = v),
                  ),
                ),
                const SizedBox(width: 16),
                if (_status != null)
                  Expanded(
                    child: Text(
                      _status!,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(color: Colors.white70),
                    ),
                  ),
              ],
            ),
            const SizedBox(height: 16),
            Expanded(
              child: Container(
                color: Colors.black,
                alignment: Alignment.center,
                child: client == null
                    ? const Text('Connect to a device to begin')
                    : AndroidMirrorView(
                        key: _mirrorKey,
                        client: client,
                        recordingOptions: _recordingOptions,
                        onEnded: (result) {
                          // The mirror died on its own (device encoder failure,
                          // stream broken): reset Start/Stop back to idle.
                          // A user-initiated stop already set its own status.
                          if (!mounted || _stoppingManually) return;
                          setState(() {
                            _running = false;
                            _status = result.producedVideo
                                ? 'Stream ended (${result.reason})'
                                : 'Failed: ${result.stderr.isNotEmpty ? result.stderr.trim().split('\n').last : result.reason}';
                          });
                        },
                        debug: true,
                      ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
1
likes
140
points
272
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter Android Bridge is a Flutter package that provides a bridge to interact with Android's native functionalities. This package allows Flutter applications to communicate with Android's package manager, intents, and other native features through a simple and intuitive API

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

copy_with_extension, flutter, intl, isolate_pool_2, meta, properties

More

Packages that depend on flutter_android_bridge