mirror of
https://github.com/openfoodfacts/smooth-app.git
synced 2025-08-26 11:16:45 +08:00
feat: Camera controller as a singleton (#1873)
* Improvements about resuming the camera * Remove unused print call * Singleton for the CameraController
This commit is contained in:
@ -1,10 +1,15 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:smooth_app/pages/scan/camera_controller.dart';
|
||||
|
||||
class CameraHelper {
|
||||
const CameraHelper._();
|
||||
|
||||
static List<CameraDescription>? _cameras;
|
||||
|
||||
/// Ensure we have a single instance of this controller
|
||||
/// /!\ Lazy-loaded
|
||||
static SmoothCameraController? _controller;
|
||||
|
||||
/// Mandatory method to call before [findBestCamera]
|
||||
static Future<void> init() async {
|
||||
_cameras = await availableCameras();
|
||||
@ -48,4 +53,12 @@ class CameraHelper {
|
||||
|
||||
return _cameras![cameraIndex];
|
||||
}
|
||||
|
||||
/// Init the controller
|
||||
/// And prevents the redefinition of it
|
||||
static void initController(SmoothCameraController controller) {
|
||||
_controller ??= controller;
|
||||
}
|
||||
|
||||
static SmoothCameraController? get controller => _controller;
|
||||
}
|
||||
|
96
packages/smooth_app/lib/pages/scan/camera_controller.dart
Normal file
96
packages/smooth_app/lib/pages/scan/camera_controller.dart
Normal file
@ -0,0 +1,96 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A lifecycle-aware [CameraController]
|
||||
class SmoothCameraController extends CameraController {
|
||||
SmoothCameraController(
|
||||
CameraDescription description,
|
||||
ResolutionPreset resolutionPreset, {
|
||||
bool? enableAudio,
|
||||
ImageFormatGroup? imageFormatGroup,
|
||||
}) : _isPaused = false,
|
||||
_isInitialized = false,
|
||||
super(
|
||||
description,
|
||||
resolutionPreset,
|
||||
enableAudio: enableAudio ?? true,
|
||||
imageFormatGroup: imageFormatGroup,
|
||||
);
|
||||
|
||||
/// Status of the preview
|
||||
bool _isPaused;
|
||||
|
||||
/// Status of the controller
|
||||
bool _isInitialized;
|
||||
|
||||
Future<void> init({
|
||||
required FocusMode focusMode,
|
||||
required Offset focusPoint,
|
||||
required DeviceOrientation deviceOrientation,
|
||||
required onLatestImageAvailable onAvailable,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
await setFocusMode(focusMode);
|
||||
await setFocusPoint(focusPoint);
|
||||
await setExposurePoint(focusPoint);
|
||||
await lockCaptureOrientation(deviceOrientation);
|
||||
await startImageStream(onAvailable);
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Please use [init] instead
|
||||
@protected
|
||||
@override
|
||||
Future<void> initialize() => super.initialize();
|
||||
|
||||
@override
|
||||
Future<void> startImageStream(onLatestImageAvailable onAvailable) {
|
||||
final Future<void> startImageStreamResult =
|
||||
super.startImageStream(onAvailable);
|
||||
_isPaused = false;
|
||||
return startImageStreamResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pausePreview() {
|
||||
final Future<void> pausePreviewResult = super.pausePreview();
|
||||
_isPaused = true;
|
||||
return pausePreviewResult;
|
||||
}
|
||||
|
||||
Future<void> resumePreviewIfNecessary() async {
|
||||
if (_isPaused) {
|
||||
return resumePreview();
|
||||
}
|
||||
}
|
||||
|
||||
/// Please use [resumePreviewIfNecessary] instead
|
||||
@protected
|
||||
@override
|
||||
Future<void> resumePreview() {
|
||||
final Future<void> resumePreviewResult = super.resumePreview();
|
||||
_isPaused = false;
|
||||
return resumePreviewResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopImageStream() {
|
||||
final Future<void> stopImageStreamResult = super.stopImageStream();
|
||||
_isPaused = false;
|
||||
return stopImageStreamResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
final Future<void> disposeResult = super.dispose();
|
||||
_isPaused = false;
|
||||
_isInitialized = false;
|
||||
return disposeResult;
|
||||
}
|
||||
|
||||
bool get isPaused => _isPaused;
|
||||
bool get isInitialized => _isInitialized;
|
||||
}
|
@ -7,21 +7,26 @@ import 'package:visibility_detector/visibility_detector.dart';
|
||||
/// [onStart] will be called only when the Widget is displayed for the first time
|
||||
/// (= during the [initState] phase)
|
||||
/// [onResume] will be called once the app is reopened (eg: the app is minimized
|
||||
/// and brought back to front) or this part of the Widget tree is visible again
|
||||
/// [onPause] will be called once the app is minimized or if this part of the
|
||||
/// tree is invisible
|
||||
/// and brought back to front)
|
||||
/// [onPause] will be called once the app is minimized
|
||||
/// [onVisible] will be called if this part of the tree is visible
|
||||
/// [onInvisible] will be called if this part of the tree is invisible
|
||||
class LifeCycleManager extends StatefulWidget {
|
||||
const LifeCycleManager({
|
||||
required this.onResume,
|
||||
required this.onPause,
|
||||
required this.child,
|
||||
this.onStart,
|
||||
this.onVisible,
|
||||
this.onInvisible,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final Function() onResume;
|
||||
final Function() onPause;
|
||||
final Function()? onStart;
|
||||
final Function()? onVisible;
|
||||
final Function()? onInvisible;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@ -68,9 +73,9 @@ class LifeCycleManagerState extends State<LifeCycleManager>
|
||||
|
||||
void _onVisibilityChanged(bool visible) {
|
||||
if (visible) {
|
||||
widget.onResume();
|
||||
widget.onVisible?.call();
|
||||
} else {
|
||||
widget.onPause();
|
||||
widget.onInvisible?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ import 'package:smooth_app/data_models/user_preferences.dart';
|
||||
import 'package:smooth_app/helpers/camera_helper.dart';
|
||||
import 'package:smooth_app/helpers/collections_helper.dart';
|
||||
import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart';
|
||||
import 'package:smooth_app/pages/scan/camera_controller.dart';
|
||||
import 'package:smooth_app/pages/scan/lifecycle_manager.dart';
|
||||
import 'package:smooth_app/pages/scan/mkit_scan_helper.dart';
|
||||
import 'package:smooth_app/widgets/lifecycle_aware_widget.dart';
|
||||
@ -70,7 +71,6 @@ class MLKitScannerPageState
|
||||
|
||||
late ContinuousScanModel _model;
|
||||
late UserPreferences _userPreferences;
|
||||
CameraController? _controller;
|
||||
CameraDescription? _camera;
|
||||
double _previewScale = 1.0;
|
||||
|
||||
@ -106,8 +106,10 @@ class MLKitScannerPageState
|
||||
// all entry points
|
||||
return LifeCycleManager(
|
||||
onStart: _startLiveFeed,
|
||||
onResume: _startLiveFeed,
|
||||
onPause: () => _stopImageStream(fromPauseEvent: true),
|
||||
onResume: _onResumeImageStream,
|
||||
onVisible: () => _onResumeImageStream(forceStartPreview: true),
|
||||
onPause: _onPauseImageStream,
|
||||
onInvisible: _onPauseImageStream,
|
||||
child: _buildScannerWidget(),
|
||||
);
|
||||
}
|
||||
@ -146,28 +148,17 @@ class MLKitScannerPageState
|
||||
);
|
||||
}
|
||||
|
||||
bool get isCameraNotInitialized {
|
||||
return _controller == null ||
|
||||
_controller!.value.isInitialized == false ||
|
||||
stoppingCamera ||
|
||||
_controller!.value.isPreviewPaused ||
|
||||
!_controller!.value.isStreamingImages;
|
||||
}
|
||||
bool get isCameraNotInitialized => _controller?.isInitialized != true;
|
||||
|
||||
Future<void> _startLiveFeed() async {
|
||||
if (_controller != null || _camera == null) {
|
||||
if (_camera == null) {
|
||||
return;
|
||||
} else if (_controller != null) {
|
||||
return _onResumeImageStream();
|
||||
}
|
||||
|
||||
stoppingCamera = false;
|
||||
|
||||
_controller = CameraController(
|
||||
_camera!,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
|
||||
// If the controller is initialized update the UI.
|
||||
_barcodeDecoder ??= MLKitScanDecoder(
|
||||
camera: _camera!,
|
||||
@ -177,47 +168,49 @@ class MLKitScannerPageState
|
||||
),
|
||||
),
|
||||
);
|
||||
_controller?.addListener(_cameraListener);
|
||||
|
||||
// Restart the subscription if necessary
|
||||
if (_streamSubscription?.isPaused == true) {
|
||||
_streamSubscription!.resume();
|
||||
} else {
|
||||
_subject
|
||||
.throttleTime(
|
||||
Duration(
|
||||
milliseconds:
|
||||
_averageProcessingTime.average(_defaultProcessingTime) *
|
||||
_processingTimeWindows,
|
||||
),
|
||||
)
|
||||
.asyncMap((CameraImage image) async {
|
||||
final DateTime start = DateTime.now();
|
||||
CameraHelper.initController(
|
||||
SmoothCameraController(
|
||||
_camera!,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
),
|
||||
);
|
||||
|
||||
final List<String?>? res =
|
||||
await _barcodeDecoder?.processImage(image);
|
||||
_controller!.addListener(_cameraListener);
|
||||
|
||||
_averageProcessingTime.add(
|
||||
DateTime.now().difference(start).inMilliseconds,
|
||||
);
|
||||
_subject
|
||||
.throttleTime(
|
||||
Duration(
|
||||
milliseconds:
|
||||
_averageProcessingTime.average(_defaultProcessingTime) *
|
||||
_processingTimeWindows,
|
||||
),
|
||||
)
|
||||
.asyncMap((CameraImage image) async {
|
||||
final DateTime start = DateTime.now();
|
||||
|
||||
return res;
|
||||
})
|
||||
.where(
|
||||
(List<String?>? barcodes) => barcodes?.isNotEmpty == true,
|
||||
)
|
||||
.cast<List<String>>()
|
||||
.listen(_onNewBarcodeDetected);
|
||||
}
|
||||
final List<String?>? res = await _barcodeDecoder?.processImage(image);
|
||||
|
||||
_averageProcessingTime.add(
|
||||
DateTime.now().difference(start).inMilliseconds,
|
||||
);
|
||||
|
||||
return res;
|
||||
})
|
||||
.where(
|
||||
(List<String?>? barcodes) => barcodes?.isNotEmpty == true,
|
||||
)
|
||||
.cast<List<String>>()
|
||||
.listen(_onNewBarcodeDetected);
|
||||
|
||||
try {
|
||||
await _controller?.initialize();
|
||||
await _controller?.setFocusMode(FocusMode.auto);
|
||||
await _controller?.setFocusPoint(_focusPoint);
|
||||
await _controller?.setExposurePoint(_focusPoint);
|
||||
await _controller?.lockCaptureOrientation(DeviceOrientation.portraitUp);
|
||||
await _controller?.startImageStream(
|
||||
(CameraImage image) => _subject.add(image),
|
||||
await _controller?.init(
|
||||
focusMode: FocusMode.auto,
|
||||
focusPoint: _focusPoint,
|
||||
deviceOrientation: DeviceOrientation.portraitUp,
|
||||
onAvailable: (CameraImage image) => _subject.add(image),
|
||||
);
|
||||
} on CameraException catch (e) {
|
||||
if (kDebugMode) {
|
||||
@ -238,33 +231,55 @@ class MLKitScannerPageState
|
||||
}
|
||||
|
||||
void _cameraListener() {
|
||||
_redrawScreen();
|
||||
|
||||
if (_controller?.value.hasError == true) {
|
||||
// TODO(M123): Handle errors better
|
||||
debugPrint(_controller!.value.errorDescription);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopImageStream({bool fromPauseEvent = false}) async {
|
||||
Future<void> _onPauseImageStream() async {
|
||||
if (stoppingCamera) {
|
||||
return;
|
||||
}
|
||||
|
||||
_controller?.pausePreview();
|
||||
_streamSubscription?.pause();
|
||||
}
|
||||
|
||||
Future<void> _onResumeImageStream({bool forceStartPreview = false}) async {
|
||||
if (stoppingCamera ||
|
||||
(!forceStartPreview && ScreenVisibilityDetector.invisible(context))) {
|
||||
return;
|
||||
}
|
||||
|
||||
_controller?.resumePreviewIfNecessary();
|
||||
stoppingCamera = false;
|
||||
|
||||
if (_streamSubscription?.isPaused == true) {
|
||||
_streamSubscription!.resume();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopImageStream() async {
|
||||
if (stoppingCamera) {
|
||||
return;
|
||||
}
|
||||
|
||||
_controller?.pausePreview();
|
||||
|
||||
stoppingCamera = true;
|
||||
_redrawScreen();
|
||||
|
||||
_controller?.removeListener(_cameraListener);
|
||||
|
||||
if (fromPauseEvent) {
|
||||
_streamSubscription?.pause();
|
||||
} else {
|
||||
await _streamSubscription?.cancel();
|
||||
}
|
||||
await _streamSubscription?.cancel();
|
||||
|
||||
await _controller?.dispose();
|
||||
await _barcodeDecoder?.dispose();
|
||||
|
||||
_barcodeDecoder = null;
|
||||
_controller = null;
|
||||
|
||||
_restartCameraIfNecessary();
|
||||
}
|
||||
@ -306,7 +321,7 @@ class MLKitScannerPageState
|
||||
void dispose() {
|
||||
// /!\ This call is a Future, which may leads to some issues.
|
||||
// This should be handled by [_restartCameraIfNecessary]
|
||||
_stopImageStream(fromPauseEvent: false);
|
||||
_stopImageStream();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -321,4 +336,6 @@ class MLKitScannerPageState
|
||||
return Offset(0.5, 0.25 / _previewScale);
|
||||
}
|
||||
}
|
||||
|
||||
SmoothCameraController? get _controller => CameraHelper.controller;
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ class ScreenVisibilityDetector extends StatefulWidget {
|
||||
|
||||
static bool visible(BuildContext context) =>
|
||||
context.read<ScreenVisibility>().isVisible;
|
||||
|
||||
static bool invisible(BuildContext context) => !visible(context);
|
||||
}
|
||||
|
||||
class _ScreenVisibilityDetectorState extends State<ScreenVisibilityDetector> {
|
||||
|
Reference in New Issue
Block a user