screen_region_capture 0.0.1
screen_region_capture: ^0.0.1 copied to clipboard
Region screen capture plugin that outputs cropped native frames.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:screen_region_capture/screen_region_capture.dart';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Screen Region Capture',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff496899)),
useMaterial3: true,
),
home: const CaptureDemoPage(),
);
}
}
class CaptureDemoPage extends StatefulWidget {
const CaptureDemoPage({super.key});
@override
State<CaptureDemoPage> createState() => _CaptureDemoPageState();
}
class _CaptureDemoPageState extends State<CaptureDemoPage>
with SingleTickerProviderStateMixin {
static const _capture = ScreenRegionCapture();
static const _outputWidth = 960;
static const _outputHeight = 540;
final _captureWindowKey = GlobalKey();
final _events = <String>[];
late final AnimationController _animation;
StreamSubscription<ScreenRegionCaptureEvent>? _subscription;
ScreenRegionCaptureFit _fit = ScreenRegionCaptureFit.aspectFit;
int _fps = 15;
bool _capturing = false;
bool _busy = false;
int? _textureId;
bool _nativePreviewReady = false;
Rect _inputLocalRect = const Rect.fromLTWH(48, 42, 212, 131);
Size _inputSceneSize = Size.zero;
@override
void initState() {
super.initState();
_animation = AnimationController(
vsync: this,
duration: const Duration(seconds: 8),
)..repeat();
_subscription = _capture.events.listen((event) {
if (event.type == ScreenRegionCaptureEventType.textureCaptureStarted ||
event.type == ScreenRegionCaptureEventType.textureFrameTick) {
setState(() => _nativePreviewReady = true);
}
final message = event.message == null
? event.type.name
: '${event.type.name}: ${event.message}';
_addEvent(message);
});
}
@override
void dispose() {
_subscription?.cancel();
_animation.dispose();
_capture.stop();
super.dispose();
}
Future<void> _start() async {
if (_busy || _capturing) {
return;
}
setState(() => _busy = true);
try {
await _revealInputRect();
await _capture.start(_config());
final textureId = await _capture.localPreviewTextureId();
setState(() {
_capturing = true;
_textureId = textureId;
_nativePreviewReady = false;
});
_addEvent('capture started');
} on Object catch (error) {
_addEvent('$error');
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
Future<void> _stop() async {
if (_busy || !_capturing) {
return;
}
setState(() => _busy = true);
try {
await _capture.stop();
setState(() {
_capturing = false;
_textureId = null;
_nativePreviewReady = false;
});
_addEvent('capture stopped');
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
Future<void> _updateRect({bool logEvent = true}) async {
if (logEvent) {
await _revealInputRect();
}
final rect = _targetRect();
if (rect == null) {
return;
}
if (_capturing) {
setState(() => _nativePreviewReady = false);
await _capture.updateRect(rect, pixelRatio: _pixelRatio);
}
if (logEvent) {
_addEvent('update rect');
}
}
ScreenRegionCaptureConfig _config() {
return ScreenRegionCaptureConfig(
rect: _targetRect() ?? Rect.zero,
fps: _fps,
outputWidth: _outputWidth,
outputHeight: _outputHeight,
fit: _fit,
pixelRatio: _pixelRatio,
androidNotification: const ScreenRegionCaptureAndroidNotification(
title: 'Screen Region Capture',
text: 'Capturing the selected demo region',
),
);
}
double get _pixelRatio => View.of(context).devicePixelRatio;
Future<void> _revealInputRect() async {
final context = _captureWindowKey.currentContext;
if (context == null) {
return;
}
await Scrollable.ensureVisible(
context,
alignment: 0.26,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
await Future<void>.delayed(const Duration(milliseconds: 40));
}
Rect? _targetRect() {
final context = _captureWindowKey.currentContext;
if (context == null) {
return null;
}
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) {
return null;
}
final origin = box.localToGlobal(Offset.zero);
return origin & box.size;
}
void _handleSceneMetrics(Rect rect, Size sceneSize) {
if ((_inputLocalRect == rect && _inputSceneSize == sceneSize) || !mounted) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
setState(() {
_inputLocalRect = rect;
_inputSceneSize = sceneSize;
});
});
}
void _setInputLocalRect(Rect rect) {
if (_inputLocalRect == rect || !mounted) {
return;
}
setState(() => _inputLocalRect = rect);
if (_capturing) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_updateRect(logEvent: false));
}
});
}
}
void _addEvent(String message) {
if (!mounted) {
return;
}
final now = DateTime.now();
final stamp =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
setState(() {
_events.insert(0, '$stamp $message');
if (_events.length > 3) {
_events.removeLast();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff5f8f9),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 22, 16, 28),
children: [
const Text(
'Screen Region Capture',
style: TextStyle(fontSize: 28, height: 1.1),
),
const SizedBox(height: 20),
_SectionHeader(
title: 'Input Rect',
trailing:
'L${_inputLocalRect.left.round()} T${_inputLocalRect.top.round()} '
'W${_inputLocalRect.width.round()} H${_inputLocalRect.height.round()}',
),
const SizedBox(height: 12),
_InputScene(
animation: _animation,
rect: _inputLocalRect,
captureWindowKey: _captureWindowKey,
onMetricsChanged: _handleSceneMetrics,
onRectChanged: _setInputLocalRect,
),
const SizedBox(height: 22),
_SectionHeader(
title: 'Cropped Output',
trailing: '$_outputWidth x $_outputHeight | ${_fit.name}',
),
const SizedBox(height: 12),
_Preview(
textureId: _nativePreviewReady ? _textureId : null,
animation: _animation,
captureRect: _inputLocalRect,
sceneSize: _inputSceneSize,
fit: _fit,
),
const SizedBox(height: 18),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: DropdownButtonFormField<ScreenRegionCaptureFit>(
initialValue: _fit,
decoration: const InputDecoration(
labelText: 'Fit mode',
border: OutlineInputBorder(),
),
items: ScreenRegionCaptureFit.values.map((fit) {
return DropdownMenuItem(
value: fit,
child: Text(fit.name),
);
}).toList(),
onChanged: (fit) {
if (fit == null) {
return;
}
setState(() => _fit = fit);
if (_capturing) {
_capture.resume(_config());
}
_addEvent('fit mode ${fit.name}');
},
),
),
const SizedBox(width: 14),
Expanded(
flex: 3,
child: TextFormField(
initialValue: '$_fps',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'FPS',
helperText: '1-60',
border: OutlineInputBorder(),
),
onChanged: (value) {
final fps = int.tryParse(value);
if (fps != null) {
_fps = fps.clamp(1, 60);
}
},
onFieldSubmitted: (_) {
if (_capturing) {
_capture.resume(_config());
}
_addEvent('fps $_fps');
},
),
),
],
),
const SizedBox(height: 18),
_DebugText(
localRect: _inputLocalRect,
globalRect: _targetRect(),
pixelRatio: _pixelRatio,
fit: _fit,
fps: _fps,
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy || _capturing ? null : _start,
icon: const Icon(Icons.circle),
label: const Text('Start'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : _updateRect,
icon: const Icon(Icons.crop_free),
label: const Text('Update'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy || !_capturing ? null : _stop,
icon: const Icon(Icons.stop),
label: const Text('Stop'),
),
),
],
),
const SizedBox(height: 20),
..._events.map(
(event) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(event, style: const TextStyle(fontSize: 18)),
),
),
],
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.trailing});
final String title;
final String trailing;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
),
),
Text(trailing, style: const TextStyle(fontSize: 17)),
],
);
}
}
class _InputScene extends StatelessWidget {
const _InputScene({
required this.animation,
required this.rect,
required this.captureWindowKey,
required this.onMetricsChanged,
required this.onRectChanged,
});
final Animation<double> animation;
final Rect rect;
final GlobalKey captureWindowKey;
final void Function(Rect rect, Size sceneSize) onMetricsChanged;
final ValueChanged<Rect> onRectChanged;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final value = animation.value;
final currentRect = _clampRect(rect, size);
final dragHitRect = currentRect.inflate(12);
onMetricsChanged(currentRect, size);
return Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: _ScenePainter(value)),
),
Positioned.fromRect(
rect: dragHitRect,
child: _EagerDragRegion(
onMove: (delta) {
onRectChanged(
_clampRect(currentRect.shift(delta), size),
);
},
child: Stack(
children: [
Positioned.fromRect(
rect: Rect.fromLTWH(
currentRect.left - dragHitRect.left,
currentRect.top - dragHitRect.top,
currentRect.width,
currentRect.height,
),
child: SizedBox(key: captureWindowKey),
),
],
),
),
),
Positioned.fromRect(
rect: currentRect.inflate(3),
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.transparent,
border: Border.all(color: Colors.white, width: 3),
),
),
),
),
Positioned(
left: currentRect.left,
top: currentRect.top,
child: _EagerDragRegion(
onMove: (delta) {
onRectChanged(
_clampRect(currentRect.shift(delta), size),
);
},
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xcc20292b),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
'${currentRect.width.round()} x ${currentRect.height.round()}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
Positioned(
left: currentRect.right - 36,
top: currentRect.bottom - 36,
width: 52,
height: 52,
child: _EagerDragRegion(
onMove: (delta) {
onRectChanged(
_clampRect(
Rect.fromLTWH(
currentRect.left,
currentRect.top,
currentRect.width + delta.dx,
currentRect.height + delta.dy,
),
size,
),
);
},
child: Center(
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xaa20292b),
),
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
Icons.open_in_full,
color: Colors.white,
size: 20,
),
),
),
),
),
),
const Positioned(
left: 28,
bottom: 22,
child: _LiveCounter(),
),
],
);
},
);
},
),
),
);
}
Rect _clampRect(Rect rect, Size size) {
final width = rect.width.clamp(80.0, size.width * 0.72).toDouble();
final height = rect.height.clamp(56.0, size.height * 0.72).toDouble();
final left = rect.left
.clamp(0.0, math.max(0.0, size.width - width))
.toDouble();
final top = rect.top
.clamp(0.0, math.max(0.0, size.height - height))
.toDouble();
return Rect.fromLTWH(left, top, width, height);
}
}
class _LiveCounter extends StatefulWidget {
const _LiveCounter();
@override
State<_LiveCounter> createState() => _LiveCounterState();
}
class _LiveCounterState extends State<_LiveCounter> {
late final Timer _timer;
int _count = 414;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 250), (_) {
setState(() => _count += 1);
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
'LIVE ${_count.toString().padLeft(4, '0')}',
style: const TextStyle(
color: Colors.white,
fontSize: 36,
height: 1,
fontWeight: FontWeight.w800,
),
);
}
}
class _EagerDragRegion extends StatelessWidget {
const _EagerDragRegion({required this.onMove, required this.child});
final ValueChanged<Offset> onMove;
final Widget child;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: {
EagerGestureRecognizer:
GestureRecognizerFactoryWithHandlers<EagerGestureRecognizer>(
EagerGestureRecognizer.new,
(recognizer) {},
),
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerMove: (event) => onMove(event.delta),
child: child,
),
);
}
}
class _Preview extends StatelessWidget {
const _Preview({
required this.textureId,
required this.animation,
required this.captureRect,
required this.sceneSize,
required this.fit,
});
final int? textureId;
final Animation<double> animation;
final Rect captureRect;
final Size sceneSize;
final ScreenRegionCaptureFit fit;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: DecoratedBox(
decoration: const BoxDecoration(color: Color(0xff0b1518)),
child: Stack(
fit: StackFit.expand,
children: [
AnimatedBuilder(
animation: animation,
builder: (context, child) {
return CustomPaint(
painter: _CroppedScenePainter(
animation.value,
captureRect,
sceneSize,
fit,
),
);
},
),
if (textureId != null) Texture(textureId: textureId!),
],
),
),
),
);
}
}
class _CroppedScenePainter extends CustomPainter {
const _CroppedScenePainter(
this.value,
this.captureRect,
this.sceneSize,
this.fit,
);
final double value;
final Rect captureRect;
final Size sceneSize;
final ScreenRegionCaptureFit fit;
@override
void paint(Canvas canvas, Size size) {
if (sceneSize.isEmpty || captureRect.isEmpty) {
return;
}
final placement = _placement(size);
canvas.save();
canvas.clipRect(Offset.zero & size);
canvas.translate(placement.left, placement.top);
canvas.scale(
placement.width / captureRect.width,
placement.height / captureRect.height,
);
canvas.translate(-captureRect.left, -captureRect.top);
_ScenePainter(value).paint(canvas, sceneSize);
canvas.restore();
}
Rect _placement(Size outputSize) {
final sourceRatio = captureRect.width / captureRect.height;
final outputRatio = outputSize.width / outputSize.height;
switch (fit) {
case ScreenRegionCaptureFit.fill:
return Offset.zero & outputSize;
case ScreenRegionCaptureFit.aspectFill:
final scale = sourceRatio > outputRatio
? outputSize.height / captureRect.height
: outputSize.width / captureRect.width;
final width = captureRect.width * scale;
final height = captureRect.height * scale;
return Rect.fromLTWH(
(outputSize.width - width) / 2,
(outputSize.height - height) / 2,
width,
height,
);
case ScreenRegionCaptureFit.center:
return Rect.fromLTWH(
(outputSize.width - captureRect.width) / 2,
(outputSize.height - captureRect.height) / 2,
captureRect.width,
captureRect.height,
);
case ScreenRegionCaptureFit.aspectFit:
final scale = sourceRatio > outputRatio
? outputSize.width / captureRect.width
: outputSize.height / captureRect.height;
final width = captureRect.width * scale;
final height = captureRect.height * scale;
return Rect.fromLTWH(
(outputSize.width - width) / 2,
(outputSize.height - height) / 2,
width,
height,
);
}
}
@override
bool shouldRepaint(_CroppedScenePainter oldDelegate) {
return oldDelegate.value != value ||
oldDelegate.captureRect != captureRect ||
oldDelegate.sceneSize != sceneSize ||
oldDelegate.fit != fit;
}
}
class _DebugText extends StatelessWidget {
const _DebugText({
required this.localRect,
required this.globalRect,
required this.pixelRatio,
required this.fit,
required this.fps,
});
final Rect localRect;
final Rect? globalRect;
final double pixelRatio;
final ScreenRegionCaptureFit fit;
final int fps;
@override
Widget build(BuildContext context) {
final nativeRect = globalRect ?? Rect.zero;
final pixelRect = Rect.fromLTWH(
nativeRect.left * pixelRatio,
nativeRect.top * pixelRatio,
nativeRect.width * pixelRatio,
nativeRect.height * pixelRatio,
);
return Text(
'Input local rect: left=${localRect.left.round()}, top=${localRect.top.round()}, '
'width=${localRect.width.round()}, height=${localRect.height.round()}\n'
'Native Flutter rect: left=${nativeRect.left.round()}, top=${nativeRect.top.round()}, '
'width=${nativeRect.width.round()}, height=${nativeRect.height.round()}\n'
'Native pixel rect: left=${pixelRect.left.round()}, top=${pixelRect.top.round()}, '
'width=${pixelRect.width.round()}, height=${pixelRect.height.round()}\n'
'Output frame: 960 x 540 @ ${fps}fps ${fit.name} demo',
style: const TextStyle(fontSize: 19, height: 1.34),
);
}
}
class _ScenePainter extends CustomPainter {
const _ScenePainter(this.value);
final double value;
@override
void paint(Canvas canvas, Size size) {
final background = Paint()
..shader = const LinearGradient(
colors: [
Color(0xff071012),
Color(0xff0d5a61),
Color(0xff17a5ac),
Color(0xffd8effb),
],
).createShader(Offset.zero & size);
canvas.drawRect(Offset.zero & size, background);
final bandLeft = lerpDouble(-size.width * 0.1, size.width * 0.8, value)!;
canvas.drawRect(
Rect.fromLTWH(bandLeft, 0, size.width * 0.12, size.height),
Paint()..color = const Color(0xbde4cc61),
);
final gridPaint = Paint()
..color = Colors.white.withValues(alpha: 0.24)
..strokeWidth = 1.5;
for (var x = 0.0; x < size.width; x += size.width / 9) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
}
for (var y = 0.0; y < size.height; y += size.height / 7) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
_circle(canvas, size, const Color(0xffdf3e6a), value, 0.12, 0.32, 0.22);
_circle(
canvas,
size,
const Color(0xffefcf5b),
value + 0.18,
0.38,
0.42,
0.2,
);
_circle(
canvas,
size,
const Color(0xff16d29f),
value + 0.42,
0.62,
0.28,
0.23,
);
_circle(
canvas,
size,
const Color(0xff0786ad),
value + 0.66,
0.83,
0.5,
0.26,
);
}
void _circle(
Canvas canvas,
Size size,
Color color,
double value,
double baseX,
double baseY,
double diameter,
) {
final center = Offset(
size.width * (baseX + math.sin(value * math.pi * 2) * 0.04),
size.height * (baseY + math.cos(value * math.pi * 2) * 0.06),
);
canvas.drawCircle(
center,
size.shortestSide * diameter / 2,
Paint()..color = color.withValues(alpha: 0.92),
);
}
@override
bool shouldRepaint(_ScenePainter oldDelegate) => oldDelegate.value != value;
}