twonly-app/lib/src/views/camera/camera_preview_controller_view.dart
2025-11-18 23:35:59 +01:00

816 lines
26 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:screenshot/screenshot.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/camera/camera_preview_components/permissions_view.dart';
import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart';
import 'package:twonly/src/views/camera/camera_preview_components/video_recording_time.dart';
import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.dart';
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/home.view.dart';
int maxVideoRecordingTime = 60;
Future<(SelectedCameraDetails, CameraController)?> initializeCameraController(
SelectedCameraDetails details,
int sCameraId,
bool init,
) async {
var cameraId = sCameraId;
if (cameraId >= gCameras.length) return null;
if (init) {
for (; cameraId < gCameras.length; cameraId++) {
if (gCameras[cameraId].lensDirection == CameraLensDirection.back) {
break;
}
}
}
details.isZoomAble = false;
if (details.cameraId != cameraId) {
// switch between front and back
details.scaleFactor = 1;
}
final cameraController = CameraController(
gCameras[cameraId],
ResolutionPreset.high,
enableAudio: await Permission.microphone.isGranted,
);
await cameraController.initialize().then((_) async {
await cameraController.setZoomLevel(details.scaleFactor);
await cameraController.lockCaptureOrientation(DeviceOrientation.portraitUp);
await cameraController
.setFlashMode(details.isFlashOn ? FlashMode.always : FlashMode.off);
await cameraController
.getMaxZoomLevel()
.then((double value) => details.maxAvailableZoom = value);
await cameraController
.getMinZoomLevel()
.then((double value) => details.minAvailableZoom = value);
details
..isZoomAble = details.maxAvailableZoom != details.minAvailableZoom
..cameraLoaded = true
..cameraId = cameraId;
}).catchError((Object e) {
Log.error('$e');
});
return (details, cameraController);
}
class SelectedCameraDetails {
double maxAvailableZoom = 1;
double minAvailableZoom = 1;
int cameraId = 0;
bool isZoomAble = false;
bool isFlashOn = false;
double scaleFactor = 1;
bool cameraLoaded = false;
}
class CameraPreviewControllerView extends StatelessWidget {
const CameraPreviewControllerView({
required this.cameraController,
required this.selectCamera,
required this.selectedCameraDetails,
required this.screenshotController,
required this.isVisible,
super.key,
this.sendToGroup,
});
final Group? sendToGroup;
final Future<CameraController?> Function(int sCameraId, bool init)
selectCamera;
final CameraController? cameraController;
final SelectedCameraDetails selectedCameraDetails;
final ScreenshotController screenshotController;
final bool isVisible;
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: checkPermissions(),
builder: (context, snap) {
if (snap.hasData) {
if (snap.data!) {
return CameraPreviewView(
sendToGroup: sendToGroup,
selectCamera: selectCamera,
cameraController: cameraController,
selectedCameraDetails: selectedCameraDetails,
screenshotController: screenshotController,
isVisible: isVisible,
);
} else {
return PermissionHandlerView(
onSuccess: () {
selectCamera(0, true);
},
);
}
} else {
return Container();
}
},
);
}
}
class CameraPreviewView extends StatefulWidget {
const CameraPreviewView({
required this.selectCamera,
required this.cameraController,
required this.selectedCameraDetails,
required this.screenshotController,
required this.isVisible,
super.key,
this.sendToGroup,
});
final Group? sendToGroup;
final Future<CameraController?> Function(
int sCameraId,
bool init,
) selectCamera;
final CameraController? cameraController;
final SelectedCameraDetails selectedCameraDetails;
final ScreenshotController screenshotController;
final bool isVisible;
@override
State<CameraPreviewView> createState() => _CameraPreviewViewState();
}
class _CameraPreviewViewState extends State<CameraPreviewView> {
bool _sharePreviewIsShown = false;
bool _galleryLoadedImageIsShown = false;
bool _showSelfieFlash = false;
double _basePanY = 0;
double _baseScaleFactor = 0;
bool _isVideoRecording = false;
bool _hasAudioPermission = true;
DateTime? _videoRecordingStarted;
Timer? _videoRecordingTimer;
DateTime _currentTime = DateTime.now();
final GlobalKey keyTriggerButton = GlobalKey();
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
StreamSubscription<HardwareButton>? androidVolumeDownSub;
@override
void initState() {
super.initState();
initVolumeControl();
initAsync();
}
@override
void didUpdateWidget(covariant CameraPreviewView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isVisible != widget.isVisible) {
if (widget.isVisible) {
initVolumeControl();
} else {
deInitVolumeControl();
}
}
}
@override
void dispose() {
_videoRecordingTimer?.cancel();
deInitVolumeControl();
super.dispose();
}
Future<void> initVolumeControl() async {
if (Platform.isIOS) {
await FlutterVolumeController.updateShowSystemUI(false);
double? startedVolume;
FlutterVolumeController.addListener(
(volume) async {
if (!widget.isVisible) {
await deInitVolumeControl();
return;
}
if (startedVolume == null) {
startedVolume = volume;
return;
}
if (startedVolume == volume) {
return;
}
// reset the volume back to the original value
await FlutterVolumeController.setVolume(startedVolume!);
await takePicture();
},
);
}
if (Platform.isAndroid) {
if ((await DeviceInfoPlugin().androidInfo).version.release == '9') {
// MissingPluginException: MissingPluginException(No implementation found for method cancel on channel dart-tools.dev/flutter_…
// Maybe this is the reason?
return;
} else {
androidVolumeDownSub =
FlutterAndroidVolumeKeydown.stream.listen((event) {
if (widget.isVisible) {
takePicture();
} else {
deInitVolumeControl();
return;
}
});
}
}
}
Future<void> deInitVolumeControl() async {
if (Platform.isIOS) {
await FlutterVolumeController.updateShowSystemUI(true);
FlutterVolumeController.removeListener();
}
if (Platform.isAndroid) {
await androidVolumeDownSub?.cancel();
}
}
Future<void> initAsync() async {
_hasAudioPermission = await Permission.microphone.isGranted;
if (!_hasAudioPermission && !gUser.requestedAudioPermission) {
await updateUserdata((u) {
u.requestedAudioPermission = true;
return u;
});
await requestMicrophonePermission();
}
if (!mounted) return;
setState(() {});
}
Future<void> requestMicrophonePermission() async {
final statuses = await [
Permission.microphone,
].request();
if (statuses[Permission.microphone]!.isPermanentlyDenied) {
await openAppSettings();
} else {
_hasAudioPermission = await Permission.microphone.isGranted;
setState(() {});
}
}
Future<void> updateScaleFactor(double newScale) async {
if (widget.selectedCameraDetails.scaleFactor == newScale ||
widget.cameraController == null) {
return;
}
await widget.cameraController?.setZoomLevel(
newScale.clamp(
widget.selectedCameraDetails.minAvailableZoom,
widget.selectedCameraDetails.maxAvailableZoom,
),
);
setState(() {
widget.selectedCameraDetails.scaleFactor = newScale;
});
}
Future<Uint8List?> loadAndDeletePictureFromFile(XFile picture) async {
try {
// Load the image into bytes
final imageBytes = await picture.readAsBytes();
// Remove the image file
await File(picture.path).delete();
return imageBytes;
} catch (e) {
if (context.mounted) {
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading picture: $e'),
duration: const Duration(seconds: 3),
),
);
}
return null;
}
}
Future<void> takePicture() async {
if (_sharePreviewIsShown || _isVideoRecording) return;
late Future<Uint8List?> imageBytes;
setState(() {
_sharePreviewIsShown = true;
});
if (widget.selectedCameraDetails.isFlashOn) {
if (isFront) {
setState(() {
_showSelfieFlash = true;
});
} else {
await widget.cameraController?.setFlashMode(FlashMode.torch);
}
await Future.delayed(const Duration(milliseconds: 1000));
}
await widget.cameraController?.pausePreview();
if (!mounted) {
return;
}
if (Platform.isIOS) {
// android has a problem with this. Flash is turned off in the pausePreview function.
await widget.cameraController?.setFlashMode(FlashMode.off);
}
if (!mounted) {
return;
}
imageBytes = widget.screenshotController
.capture(pixelRatio: MediaQuery.of(context).devicePixelRatio);
if (await pushMediaEditor(imageBytes, null)) {
return;
}
setState(() {
_sharePreviewIsShown = false;
});
}
Future<bool> pushMediaEditor(
Future<Uint8List?>? imageBytes,
File? videoFilePath, {
bool sharedFromGallery = false,
MediaType? mediaType,
}) async {
final type = mediaType ??
((videoFilePath != null) ? MediaType.video : MediaType.image);
final mediaFileService = await initializeMediaUpload(
type,
gUser.defaultShowTime,
isDraftMedia: true,
);
if (!mounted) return true;
if (mediaFileService == null) {
Log.error('Could not generate media file service');
return false;
}
if (videoFilePath != null) {
videoFilePath
..copySync(mediaFileService.originalPath.path)
..deleteSync();
// Start with compressing the video, to speed up the process in case the video is not changed.
// unawaited(mediaFileService.compressMedia());
}
await deInitVolumeControl();
if (!mounted) return true;
final shouldReturn = await Navigator.push(
context,
PageRouteBuilder(
opaque: false,
pageBuilder: (context, a1, a2) => ShareImageEditorView(
imageBytesFuture: imageBytes,
sharedFromGallery: sharedFromGallery,
sendToGroup: widget.sendToGroup,
mediaFileService: mediaFileService,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child;
},
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
) as bool?;
if (mounted) {
setState(() {
_sharePreviewIsShown = false;
_showSelfieFlash = false;
});
}
if (!mounted) return true;
await initVolumeControl();
// shouldReturn is null when the user used the back button
if (shouldReturn != null && shouldReturn) {
if (widget.sendToGroup == null) {
globalUpdateOfHomeViewPageIndex(0);
} else if (mounted) {
Navigator.pop(context);
}
return true;
}
await widget.selectCamera(
widget.selectedCameraDetails.cameraId,
false,
);
return false;
}
bool get isFront =>
widget.cameraController?.description.lensDirection ==
CameraLensDirection.front;
Future<void> onPanUpdate(dynamic details) async {
if (isFront || details == null) {
return;
}
if (widget.cameraController == null ||
!widget.cameraController!.value.isInitialized) {
return;
}
widget.selectedCameraDetails.scaleFactor = (_baseScaleFactor +
// ignore: avoid_dynamic_calls
(_basePanY - (details.localPosition.dy as double)) / 30)
.clamp(1, widget.selectedCameraDetails.maxAvailableZoom);
await widget.cameraController!
.setZoomLevel(widget.selectedCameraDetails.scaleFactor);
if (mounted) {
setState(() {});
}
}
Future<void> pickImageFromGallery() async {
setState(() {
_galleryLoadedImageIsShown = true;
_sharePreviewIsShown = true;
});
final picker = ImagePicker();
final pickedFile = await picker.pickMedia();
if (pickedFile != null) {
final imageExtensions = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.heic',
'.heif',
'.avif',
];
Log.info('Picket from gallery: ${pickedFile.path}');
File? videoFilePath;
Future<Uint8List>? imageBytes;
MediaType? mediaType;
final isImage =
imageExtensions.any((ext) => pickedFile.name.contains(ext));
if (isImage) {
if (pickedFile.name.contains('.gif')) {
mediaType = MediaType.gif;
}
imageBytes = pickedFile.readAsBytes();
} else {
videoFilePath = File(pickedFile.path);
}
await pushMediaEditor(
imageBytes,
videoFilePath,
sharedFromGallery: true,
mediaType: mediaType,
);
}
setState(() {
_galleryLoadedImageIsShown = false;
_sharePreviewIsShown = false;
});
}
Future<void> startVideoRecording() async {
if (widget.cameraController != null &&
widget.cameraController!.value.isRecordingVideo) {
return;
}
setState(() {
_isVideoRecording = true;
});
try {
await widget.cameraController?.startVideoRecording();
_videoRecordingTimer =
Timer.periodic(const Duration(milliseconds: 15), (timer) {
setState(() {
_currentTime = DateTime.now();
});
if (_videoRecordingStarted != null &&
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
maxVideoRecordingTime) {
timer.cancel();
_videoRecordingTimer = null;
stopVideoRecording();
}
});
setState(() {
_videoRecordingStarted = DateTime.now();
_isVideoRecording = true;
});
} on CameraException catch (e) {
setState(() {
_isVideoRecording = false;
});
_showCameraException(e);
return;
}
}
Future<void> stopVideoRecording() async {
if (_videoRecordingTimer != null) {
_videoRecordingTimer?.cancel();
_videoRecordingTimer = null;
}
setState(() {
_videoRecordingStarted = null;
_isVideoRecording = false;
});
if (widget.cameraController == null ||
!widget.cameraController!.value.isRecordingVideo) {
return;
}
setState(() {
_sharePreviewIsShown = true;
});
try {
final videoPath = await widget.cameraController?.stopVideoRecording();
if (videoPath == null) return;
await widget.cameraController?.pausePreview();
if (await pushMediaEditor(null, File(videoPath.path))) {
return;
}
} on CameraException catch (e) {
_showCameraException(e);
return;
}
}
void _showCameraException(dynamic e) {
Log.error('$e');
try {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
duration: const Duration(seconds: 3),
),
);
}
// ignore: empty_catches
} catch (e) {}
}
@override
Widget build(BuildContext context) {
if (widget.selectedCameraDetails.cameraId >= gCameras.length ||
widget.cameraController == null) {
return Container();
}
return MediaViewSizing(
requiredHeight: 80,
bottomNavigation: Container(),
child: GestureDetector(
onPanStart: (details) async {
if (isFront) {
return;
}
setState(() {
_basePanY = details.localPosition.dy;
_baseScaleFactor = widget.selectedCameraDetails.scaleFactor;
});
},
onLongPressMoveUpdate: onPanUpdate,
onLongPressStart: (details) {
setState(() {
_basePanY = details.localPosition.dy;
_baseScaleFactor = widget.selectedCameraDetails.scaleFactor;
});
// Get the position of the pointer
final renderBox =
keyTriggerButton.currentContext!.findRenderObject()! as RenderBox;
final localPosition = renderBox.globalToLocal(details.globalPosition);
final containerRect =
Rect.fromLTWH(0, 0, renderBox.size.width, renderBox.size.height);
if (containerRect.contains(localPosition)) {
startVideoRecording();
}
},
onLongPressEnd: (a) {
stopVideoRecording();
},
onPanEnd: (a) {
stopVideoRecording();
},
onPanUpdate: onPanUpdate,
child: Stack(
children: [
if (_galleryLoadedImageIsShown)
Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 1,
color: context.color.primary,
),
),
),
if (!_sharePreviewIsShown &&
widget.sendToGroup != null &&
!_isVideoRecording)
SendToWidget(sendTo: widget.sendToGroup!.groupName),
if (!_sharePreviewIsShown && !_isVideoRecording)
Positioned(
right: 5,
top: 0,
child: Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 16),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ActionButton(
Icons.repeat_rounded,
tooltipText: context.lang.switchFrontAndBackCamera,
onPressed: () async {
await widget.selectCamera(
(widget.selectedCameraDetails.cameraId + 1) % 2,
false,
);
},
),
ActionButton(
widget.selectedCameraDetails.isFlashOn
? Icons.flash_on_rounded
: Icons.flash_off_rounded,
tooltipText: context.lang.toggleFlashLight,
color: widget.selectedCameraDetails.isFlashOn
? Colors.white
: Colors.white.withAlpha(160),
onPressed: () async {
if (widget.selectedCameraDetails.isFlashOn) {
await widget.cameraController
?.setFlashMode(FlashMode.off);
widget.selectedCameraDetails.isFlashOn = false;
} else {
await widget.cameraController
?.setFlashMode(FlashMode.always);
widget.selectedCameraDetails.isFlashOn = true;
}
setState(() {});
},
),
if (!_hasAudioPermission)
ActionButton(
Icons.mic_off_rounded,
color: Colors.white.withAlpha(160),
tooltipText:
'Allow microphone access for video recording.',
onPressed: requestMicrophonePermission,
),
],
),
),
),
),
if (!_sharePreviewIsShown)
Positioned(
bottom: 30,
left: 0,
right: 0,
child: Align(
alignment: Alignment.bottomCenter,
child: Column(
children: [
if (widget.cameraController!.value.isInitialized &&
widget.selectedCameraDetails.isZoomAble &&
!isFront &&
!_isVideoRecording)
SizedBox(
width: 120,
child: CameraZoomButtons(
key: widget.key,
scaleFactor:
widget.selectedCameraDetails.scaleFactor,
updateScaleFactor: updateScaleFactor,
selectCamera: widget.selectCamera,
selectedCameraDetails: widget.selectedCameraDetails,
controller: widget.cameraController!,
),
),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isVideoRecording)
GestureDetector(
onTap: pickImageFromGallery,
child: Align(
child: Container(
height: 50,
width: 80,
padding: const EdgeInsets.all(2),
child: const Center(
child: FaIcon(
FontAwesomeIcons.photoFilm,
color: Colors.white,
size: 25,
),
),
),
),
),
GestureDetector(
onTap: takePicture,
// onLongPress: startVideoRecording,
key: keyTriggerButton,
child: Align(
child: Container(
height: 100,
width: 100,
clipBehavior: Clip.antiAliasWithSaveLayer,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
width: 7,
color: _isVideoRecording
? Colors.red
: Colors.white,
),
),
),
),
),
if (!_isVideoRecording) const SizedBox(width: 80),
],
),
],
),
),
),
VideoRecordingTimer(
videoRecordingStarted: _videoRecordingStarted,
maxVideoRecordingTime: maxVideoRecordingTime,
),
if (!_sharePreviewIsShown && widget.sendToGroup != null)
Positioned(
left: 5,
top: 10,
child: ActionButton(
FontAwesomeIcons.xmark,
tooltipText: context.lang.close,
onPressed: () async {
Navigator.pop(context);
},
),
),
if (_showSelfieFlash)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Container(
color: Colors.white,
),
),
),
],
),
),
);
}
}