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:camera/camera.dart';
|
||||||
|
import 'package:smooth_app/pages/scan/camera_controller.dart';
|
||||||
|
|
||||||
class CameraHelper {
|
class CameraHelper {
|
||||||
const CameraHelper._();
|
const CameraHelper._();
|
||||||
|
|
||||||
static List<CameraDescription>? _cameras;
|
static List<CameraDescription>? _cameras;
|
||||||
|
|
||||||
|
/// Ensure we have a single instance of this controller
|
||||||
|
/// /!\ Lazy-loaded
|
||||||
|
static SmoothCameraController? _controller;
|
||||||
|
|
||||||
/// Mandatory method to call before [findBestCamera]
|
/// Mandatory method to call before [findBestCamera]
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
_cameras = await availableCameras();
|
_cameras = await availableCameras();
|
||||||
@ -48,4 +53,12 @@ class CameraHelper {
|
|||||||
|
|
||||||
return _cameras![cameraIndex];
|
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
|
/// [onStart] will be called only when the Widget is displayed for the first time
|
||||||
/// (= during the [initState] phase)
|
/// (= during the [initState] phase)
|
||||||
/// [onResume] will be called once the app is reopened (eg: the app is minimized
|
/// [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
|
/// and brought back to front)
|
||||||
/// [onPause] will be called once the app is minimized or if this part of the
|
/// [onPause] will be called once the app is minimized
|
||||||
/// tree is invisible
|
/// [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 {
|
class LifeCycleManager extends StatefulWidget {
|
||||||
const LifeCycleManager({
|
const LifeCycleManager({
|
||||||
required this.onResume,
|
required this.onResume,
|
||||||
required this.onPause,
|
required this.onPause,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.onStart,
|
this.onStart,
|
||||||
|
this.onVisible,
|
||||||
|
this.onInvisible,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final Function() onResume;
|
final Function() onResume;
|
||||||
final Function() onPause;
|
final Function() onPause;
|
||||||
final Function()? onStart;
|
final Function()? onStart;
|
||||||
|
final Function()? onVisible;
|
||||||
|
final Function()? onInvisible;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -68,9 +73,9 @@ class LifeCycleManagerState extends State<LifeCycleManager>
|
|||||||
|
|
||||||
void _onVisibilityChanged(bool visible) {
|
void _onVisibilityChanged(bool visible) {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
widget.onResume();
|
widget.onVisible?.call();
|
||||||
} else {
|
} 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/camera_helper.dart';
|
||||||
import 'package:smooth_app/helpers/collections_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/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/lifecycle_manager.dart';
|
||||||
import 'package:smooth_app/pages/scan/mkit_scan_helper.dart';
|
import 'package:smooth_app/pages/scan/mkit_scan_helper.dart';
|
||||||
import 'package:smooth_app/widgets/lifecycle_aware_widget.dart';
|
import 'package:smooth_app/widgets/lifecycle_aware_widget.dart';
|
||||||
@ -70,7 +71,6 @@ class MLKitScannerPageState
|
|||||||
|
|
||||||
late ContinuousScanModel _model;
|
late ContinuousScanModel _model;
|
||||||
late UserPreferences _userPreferences;
|
late UserPreferences _userPreferences;
|
||||||
CameraController? _controller;
|
|
||||||
CameraDescription? _camera;
|
CameraDescription? _camera;
|
||||||
double _previewScale = 1.0;
|
double _previewScale = 1.0;
|
||||||
|
|
||||||
@ -106,8 +106,10 @@ class MLKitScannerPageState
|
|||||||
// all entry points
|
// all entry points
|
||||||
return LifeCycleManager(
|
return LifeCycleManager(
|
||||||
onStart: _startLiveFeed,
|
onStart: _startLiveFeed,
|
||||||
onResume: _startLiveFeed,
|
onResume: _onResumeImageStream,
|
||||||
onPause: () => _stopImageStream(fromPauseEvent: true),
|
onVisible: () => _onResumeImageStream(forceStartPreview: true),
|
||||||
|
onPause: _onPauseImageStream,
|
||||||
|
onInvisible: _onPauseImageStream,
|
||||||
child: _buildScannerWidget(),
|
child: _buildScannerWidget(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -146,28 +148,17 @@ class MLKitScannerPageState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isCameraNotInitialized {
|
bool get isCameraNotInitialized => _controller?.isInitialized != true;
|
||||||
return _controller == null ||
|
|
||||||
_controller!.value.isInitialized == false ||
|
|
||||||
stoppingCamera ||
|
|
||||||
_controller!.value.isPreviewPaused ||
|
|
||||||
!_controller!.value.isStreamingImages;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startLiveFeed() async {
|
Future<void> _startLiveFeed() async {
|
||||||
if (_controller != null || _camera == null) {
|
if (_camera == null) {
|
||||||
return;
|
return;
|
||||||
|
} else if (_controller != null) {
|
||||||
|
return _onResumeImageStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
stoppingCamera = false;
|
stoppingCamera = false;
|
||||||
|
|
||||||
_controller = CameraController(
|
|
||||||
_camera!,
|
|
||||||
ResolutionPreset.medium,
|
|
||||||
enableAudio: false,
|
|
||||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the controller is initialized update the UI.
|
// If the controller is initialized update the UI.
|
||||||
_barcodeDecoder ??= MLKitScanDecoder(
|
_barcodeDecoder ??= MLKitScanDecoder(
|
||||||
camera: _camera!,
|
camera: _camera!,
|
||||||
@ -177,47 +168,49 @@ class MLKitScannerPageState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_controller?.addListener(_cameraListener);
|
|
||||||
|
|
||||||
// Restart the subscription if necessary
|
CameraHelper.initController(
|
||||||
if (_streamSubscription?.isPaused == true) {
|
SmoothCameraController(
|
||||||
_streamSubscription!.resume();
|
_camera!,
|
||||||
} else {
|
ResolutionPreset.medium,
|
||||||
_subject
|
enableAudio: false,
|
||||||
.throttleTime(
|
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||||
Duration(
|
),
|
||||||
milliseconds:
|
);
|
||||||
_averageProcessingTime.average(_defaultProcessingTime) *
|
|
||||||
_processingTimeWindows,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.asyncMap((CameraImage image) async {
|
|
||||||
final DateTime start = DateTime.now();
|
|
||||||
|
|
||||||
final List<String?>? res =
|
_controller!.addListener(_cameraListener);
|
||||||
await _barcodeDecoder?.processImage(image);
|
|
||||||
|
|
||||||
_averageProcessingTime.add(
|
_subject
|
||||||
DateTime.now().difference(start).inMilliseconds,
|
.throttleTime(
|
||||||
);
|
Duration(
|
||||||
|
milliseconds:
|
||||||
|
_averageProcessingTime.average(_defaultProcessingTime) *
|
||||||
|
_processingTimeWindows,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.asyncMap((CameraImage image) async {
|
||||||
|
final DateTime start = DateTime.now();
|
||||||
|
|
||||||
return res;
|
final List<String?>? res = await _barcodeDecoder?.processImage(image);
|
||||||
})
|
|
||||||
.where(
|
_averageProcessingTime.add(
|
||||||
(List<String?>? barcodes) => barcodes?.isNotEmpty == true,
|
DateTime.now().difference(start).inMilliseconds,
|
||||||
)
|
);
|
||||||
.cast<List<String>>()
|
|
||||||
.listen(_onNewBarcodeDetected);
|
return res;
|
||||||
}
|
})
|
||||||
|
.where(
|
||||||
|
(List<String?>? barcodes) => barcodes?.isNotEmpty == true,
|
||||||
|
)
|
||||||
|
.cast<List<String>>()
|
||||||
|
.listen(_onNewBarcodeDetected);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _controller?.initialize();
|
await _controller?.init(
|
||||||
await _controller?.setFocusMode(FocusMode.auto);
|
focusMode: FocusMode.auto,
|
||||||
await _controller?.setFocusPoint(_focusPoint);
|
focusPoint: _focusPoint,
|
||||||
await _controller?.setExposurePoint(_focusPoint);
|
deviceOrientation: DeviceOrientation.portraitUp,
|
||||||
await _controller?.lockCaptureOrientation(DeviceOrientation.portraitUp);
|
onAvailable: (CameraImage image) => _subject.add(image),
|
||||||
await _controller?.startImageStream(
|
|
||||||
(CameraImage image) => _subject.add(image),
|
|
||||||
);
|
);
|
||||||
} on CameraException catch (e) {
|
} on CameraException catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
@ -238,33 +231,55 @@ class MLKitScannerPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _cameraListener() {
|
void _cameraListener() {
|
||||||
|
_redrawScreen();
|
||||||
|
|
||||||
if (_controller?.value.hasError == true) {
|
if (_controller?.value.hasError == true) {
|
||||||
// TODO(M123): Handle errors better
|
// TODO(M123): Handle errors better
|
||||||
debugPrint(_controller!.value.errorDescription);
|
debugPrint(_controller!.value.errorDescription);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stopImageStream({bool fromPauseEvent = false}) async {
|
Future<void> _onPauseImageStream() async {
|
||||||
if (stoppingCamera) {
|
if (stoppingCamera) {
|
||||||
return;
|
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;
|
stoppingCamera = true;
|
||||||
_redrawScreen();
|
_redrawScreen();
|
||||||
|
|
||||||
_controller?.removeListener(_cameraListener);
|
_controller?.removeListener(_cameraListener);
|
||||||
|
|
||||||
if (fromPauseEvent) {
|
await _streamSubscription?.cancel();
|
||||||
_streamSubscription?.pause();
|
|
||||||
} else {
|
|
||||||
await _streamSubscription?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _controller?.dispose();
|
await _controller?.dispose();
|
||||||
await _barcodeDecoder?.dispose();
|
await _barcodeDecoder?.dispose();
|
||||||
|
|
||||||
_barcodeDecoder = null;
|
_barcodeDecoder = null;
|
||||||
_controller = null;
|
|
||||||
|
|
||||||
_restartCameraIfNecessary();
|
_restartCameraIfNecessary();
|
||||||
}
|
}
|
||||||
@ -306,7 +321,7 @@ class MLKitScannerPageState
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
// /!\ This call is a Future, which may leads to some issues.
|
// /!\ This call is a Future, which may leads to some issues.
|
||||||
// This should be handled by [_restartCameraIfNecessary]
|
// This should be handled by [_restartCameraIfNecessary]
|
||||||
_stopImageStream(fromPauseEvent: false);
|
_stopImageStream();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,4 +336,6 @@ class MLKitScannerPageState
|
|||||||
return Offset(0.5, 0.25 / _previewScale);
|
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) =>
|
static bool visible(BuildContext context) =>
|
||||||
context.read<ScreenVisibility>().isVisible;
|
context.read<ScreenVisibility>().isVisible;
|
||||||
|
|
||||||
|
static bool invisible(BuildContext context) => !visible(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScreenVisibilityDetectorState extends State<ScreenVisibilityDetector> {
|
class _ScreenVisibilityDetectorState extends State<ScreenVisibilityDetector> {
|
||||||
|
Reference in New Issue
Block a user