302 lines
9.2 KiB
Dart
302 lines
9.2 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:camera/camera.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter/widgets.dart';
|
||
|
||
import 'frame_analyzer.dart';
|
||
|
||
/// 相机抽象(对应 Kotlin CameraController):
|
||
///
|
||
/// - Android:自写原生通道([NativeCameraController])。分析帧在 Kotlin 侧
|
||
/// 旋转成竖屏后经 EventChannel 回传,Flutter 侧恒 rotation=0——与 iOS
|
||
/// (camera_avfoundation 插件,帧本来就是竖屏方向)行为一致,消除
|
||
/// "横屏传感器帧 → 90° 旋转 + FIT_COVER crop 映射"的标注偏移根因。
|
||
/// - iOS:camera 插件(原逻辑)。帧已竖屏,rotation 恒 0。
|
||
abstract class AppCameraController {
|
||
static Future<AppCameraController?> create() async {
|
||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||
return NativeCameraController();
|
||
}
|
||
return PluginCameraController.create();
|
||
}
|
||
|
||
/// 分析流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
|
||
int get streamCallbacks;
|
||
|
||
bool get isInitialized;
|
||
|
||
bool get isStreaming;
|
||
|
||
String? get errorDescription;
|
||
|
||
/// 检测框 overlay 应使用的旋转角。两平台帧都已竖屏 → 恒 0。
|
||
int get rotationDegrees => 0;
|
||
|
||
/// 诊断:传感器方向 / 显示旋转(仅 Android 原生通道上报)
|
||
int? get sensorOrientation => null;
|
||
|
||
int? get displayDegrees => null;
|
||
|
||
/// 预览实际应用的旋转圈数(仅 Android 原生通道上报,诊断用)
|
||
int get quarterTurns => -1;
|
||
|
||
/// 诊断:轮询原生侧帧状态(Android 返回计数;iOS 返回空)
|
||
Future<Map<dynamic, dynamic>> stats() async => const {};
|
||
|
||
Future<void> start(FrameAnalyzer analyzer);
|
||
|
||
Future<void> stop();
|
||
|
||
Future<double> getMinZoomLevel();
|
||
|
||
Future<double> getMaxZoomLevel();
|
||
|
||
Future<void> setZoomLevel(double value);
|
||
|
||
/// 预览 widget:Android 为原生 SurfaceView(AndroidView),iOS 为插件纹理
|
||
Widget buildPreview();
|
||
}
|
||
|
||
/// Android:自写原生相机通道(Kotlin CameraChannel)。
|
||
class NativeCameraController extends AppCameraController {
|
||
static const MethodChannel _channel = MethodChannel('observer/camera');
|
||
static const EventChannel _frames = EventChannel('observer/camera/frames');
|
||
|
||
StreamSubscription<dynamic>? _sub;
|
||
bool _streaming = false;
|
||
String? _error;
|
||
|
||
/// 原生侧注册的预览纹理(SurfaceTexture)
|
||
int? _textureId;
|
||
int _textureW = 1920;
|
||
int _textureH = 1080;
|
||
int _quarterTurns = 1;
|
||
|
||
@override
|
||
int streamCallbacks = 0;
|
||
|
||
int? _sensorOrientation;
|
||
int? _displayDegrees;
|
||
|
||
@override
|
||
int? get sensorOrientation => _sensorOrientation;
|
||
|
||
@override
|
||
int? get displayDegrees => _displayDegrees;
|
||
|
||
@override
|
||
int get quarterTurns => _quarterTurns;
|
||
|
||
@override
|
||
bool get isInitialized => _streaming;
|
||
|
||
@override
|
||
bool get isStreaming => _streaming;
|
||
|
||
@override
|
||
String? get errorDescription => _error;
|
||
|
||
@override
|
||
Future<Map<dynamic, dynamic>> stats() async {
|
||
try {
|
||
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('stats');
|
||
if (r != null) {
|
||
// 旋转/显示角度随轮询实时刷新:手机旋转后预览与帧旋转都跟着变
|
||
final dd = r['displayDegrees'];
|
||
if (dd is int) _displayDegrees = dd;
|
||
final turns = r['quarterTurns'];
|
||
if (turns is int) _quarterTurns = turns;
|
||
}
|
||
return r ?? const {};
|
||
} catch (e) {
|
||
return {'pollErr': '$e'};
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> start(FrameAnalyzer analyzer) async {
|
||
await stop();
|
||
// 先订阅再启动:原生 start 绑定后立刻推帧,避免首帧竞态
|
||
_sub = _frames.receiveBroadcastStream().listen((event) {
|
||
// 计数放最前:任何到达的事件都先记账,后续解析失败也不丢计数
|
||
streamCallbacks++;
|
||
try {
|
||
final f = event as List;
|
||
final w = f[0] as int;
|
||
final h = f[1] as int;
|
||
final rotation = f[2] as int;
|
||
final bgra = f[3] as bool;
|
||
final bytes = f[4] as Uint8List;
|
||
analyzer.analyzeRaw(
|
||
planes: [bytes],
|
||
strides: [w * 4],
|
||
width: w,
|
||
height: h,
|
||
isBgra: true,
|
||
rgbaOrder: !bgra,
|
||
rotationDegrees: rotation,
|
||
);
|
||
} catch (e) {
|
||
_error = '帧解析: $e';
|
||
analyzer.recordStreamError('frames parse: $e');
|
||
}
|
||
}, onError: (Object e) {
|
||
_error = '$e';
|
||
analyzer.recordStreamError('frames: $e');
|
||
});
|
||
analyzer.reset();
|
||
analyzer.worker?.reset();
|
||
try {
|
||
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('start');
|
||
_textureId = r?['textureId'] as int?;
|
||
_textureW = (r?['w'] as num?)?.toInt() ?? _textureW;
|
||
_textureH = (r?['h'] as num?)?.toInt() ?? _textureH;
|
||
_quarterTurns = (r?['quarterTurns'] as num?)?.toInt() ?? 1;
|
||
_sensorOrientation = (r?['sensorOrientation'] as num?)?.toInt();
|
||
_displayDegrees = (r?['displayDegrees'] as num?)?.toInt();
|
||
} catch (e) {
|
||
_error = '$e';
|
||
analyzer.recordStreamError('camera start: $e');
|
||
rethrow;
|
||
}
|
||
_streaming = true;
|
||
}
|
||
|
||
@override
|
||
Future<void> stop() async {
|
||
_streaming = false;
|
||
await _sub?.cancel();
|
||
_sub = null;
|
||
try {
|
||
await _channel.invokeMethod('stop');
|
||
} catch (_) {}
|
||
}
|
||
|
||
@override
|
||
Future<double> getMinZoomLevel() async {
|
||
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
|
||
return (r != null && r.isNotEmpty ? (r[0] as num).toDouble() : 1.0);
|
||
}
|
||
|
||
@override
|
||
Future<double> getMaxZoomLevel() async {
|
||
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
|
||
return (r != null && r.length > 1 ? (r[1] as num).toDouble() : 1.0);
|
||
}
|
||
|
||
@override
|
||
Future<void> setZoomLevel(double value) async {
|
||
try {
|
||
await _channel.invokeMethod('setZoom', value);
|
||
} catch (_) {}
|
||
}
|
||
|
||
/// 预览纹理:Flutter 引擎渲染 Texture 时已自动应用 SurfaceTexture 变换矩阵
|
||
/// (传感器方向补偿,内容已转成自然方向的竖屏),因此:
|
||
/// 1. 区域声明旋转后的尺寸(宽高互换)——否则横屏区域会把竖屏内容横向拉伸
|
||
/// 2. RotatedBox 只按显示旋转补偿(quarterTurns = 屏转/90,竖屏 0 / 横屏 1)
|
||
/// 再 FittedBox cover(= CoordinateMapper 的 FIT_COVER 数学一致)填满全屏。
|
||
/// 不用 AndroidView+SurfaceView——平台视图会盖住 Flutter UI(诊断行/设置按钮/overlay)
|
||
@override
|
||
Widget buildPreview() {
|
||
final id = _textureId;
|
||
if (id == null) return const SizedBox.shrink();
|
||
return FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: RotatedBox(
|
||
quarterTurns: _quarterTurns % 4,
|
||
child: SizedBox(
|
||
width: _textureH.toDouble(),
|
||
height: _textureW.toDouble(),
|
||
child: Texture(textureId: id),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// iOS:camera 插件封装(原实现)。
|
||
class PluginCameraController extends AppCameraController {
|
||
final List<CameraDescription> cameras;
|
||
CameraController? controller;
|
||
|
||
@override
|
||
int streamCallbacks = 0;
|
||
|
||
PluginCameraController._(this.cameras);
|
||
|
||
static Future<PluginCameraController?> create() async {
|
||
final cameras = await availableCameras();
|
||
if (cameras.isEmpty) return null;
|
||
return PluginCameraController._(cameras);
|
||
}
|
||
|
||
@override
|
||
bool get isInitialized => controller?.value.isInitialized ?? false;
|
||
|
||
@override
|
||
bool get isStreaming => controller?.value.isStreamingImages ?? false;
|
||
|
||
@override
|
||
String? get errorDescription => controller?.value.errorDescription;
|
||
|
||
@override
|
||
Future<void> start(FrameAnalyzer analyzer) async {
|
||
await stop();
|
||
final desc = cameras.firstWhere(
|
||
(c) => c.lensDirection == CameraLensDirection.back,
|
||
orElse: () => cameras.first);
|
||
// iOS image stream 帧已按竖屏方向输出(无需旋转),
|
||
// 与 Android 原生通道(帧原生侧旋转成竖屏)统一 rotation=0
|
||
final c = CameraController(desc, ResolutionPreset.veryHigh,
|
||
enableAudio: false, imageFormatGroup: ImageFormatGroup.bgra8888);
|
||
controller = c;
|
||
await c.initialize();
|
||
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
|
||
analyzer.reset();
|
||
analyzer.worker?.reset();
|
||
debugPrint('[camera] initialized, starting image stream');
|
||
try {
|
||
await c.startImageStream((image) {
|
||
streamCallbacks++;
|
||
try {
|
||
analyzer.analyze(image, 0);
|
||
} catch (e, st) {
|
||
debugPrint('[camera] analyze error: $e\n$st');
|
||
analyzer.recordStreamError('analyze: $e');
|
||
}
|
||
});
|
||
debugPrint('[camera] startImageStream ok');
|
||
} catch (e, st) {
|
||
debugPrint('[camera] startImageStream FAILED: $e\n$st');
|
||
analyzer.recordStreamError('startImageStream: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> stop() async {
|
||
final c = controller;
|
||
if (c == null) return;
|
||
controller = null;
|
||
try {
|
||
await c.stopImageStream();
|
||
} catch (_) {}
|
||
await c.dispose();
|
||
}
|
||
|
||
@override
|
||
Future<double> getMinZoomLevel() => controller!.getMinZoomLevel();
|
||
|
||
@override
|
||
Future<double> getMaxZoomLevel() => controller!.getMaxZoomLevel();
|
||
|
||
@override
|
||
Future<void> setZoomLevel(double value) => controller!.setZoomLevel(value);
|
||
|
||
@override
|
||
Widget buildPreview() => CameraPreview(controller!);
|
||
}
|