flutter_android_bridge 0.3.1
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 [...]
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,
),
),
),
],
),
),
);
}
}