screen_region_capture 0.0.1 copy "screen_region_capture: ^0.0.1" to clipboard
screen_region_capture: ^0.0.1 copied to clipboard

Region screen capture plugin that outputs cropped native frames.

example/lib/main.dart

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;
}