Files
SDKmuta/lib/screens/stream_screen.dart
T

960 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:screenshot/screenshot.dart';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
import 'package:path_provider/path_provider.dart';
import 'package:image/image.dart' as img;
import 'yolo_detector.dart';
import 'preview_screen.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../utils/translations.dart';
enum ImageFilterType { none, greyscale, invert}
class CameraStreamScreen extends StatefulWidget {
final int cameraIndex;
const CameraStreamScreen({super.key, required this.cameraIndex});
@override
State<CameraStreamScreen> createState() => _CameraStreamScreenState();
}
class _CameraStreamScreenState extends State<CameraStreamScreen> with SingleTickerProviderStateMixin {
String? _streamUrl;
bool _isError = false;
bool _isCapturing = false;
final ScreenshotController _screenshotController = ScreenshotController();
final TransformationController _transformationController = TransformationController();
ImageFilterType _selectedFilter = ImageFilterType.none;
late YOLODetector _yoloDetector;
int _numberOfBullets = 5;
// Brightness and contrast controls
double _brightness = 0.0; // -1.0 to 1.0
double _contrast = 1.0; // 0.0 to 2.0
late AnimationController _zoomAnimController;
Animation<Matrix4>? _zoomAnimation;
Matrix4? _originalMatrix;
Color _bulletColor = Colors.blue; // default/fallback bullet color
String _selectedLanguage = 'English';
String _t(String key) {
return AppTranslations.translate(key, _selectedLanguage);
}
@override
void initState() {
super.initState();
_loadLanguage();
_loadStreamUrl();
_loadBulletCount();
_loadSelectedFilter();
_loadBulletColor(); // 👈 add this to actually load the saved bullet color
_loadBrightnessContrast();
YOLODetector.create().then((detector) => _yoloDetector = detector);
_setSystemBarStyle(); // 👈 set colors
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // 👈 show bars
_keepScreenAwake();
_zoomAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
Future<void> _loadBrightnessContrast() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_brightness = prefs.getDouble('brightness') ?? 0.0;
_contrast = prefs.getDouble('contrast') ?? 1.0;
});
}
Future<void> _saveBrightnessContrast() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('brightness', _brightness);
await prefs.setDouble('contrast', _contrast);
}
Future<void> _loadLanguage() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedLanguage = prefs.getString('language') ?? 'English';
});
}
Future<void> _loadBulletColor() async {
final prefs = await SharedPreferences.getInstance();
final selected = prefs.getString('bulletColor') ?? 'Blue';
setState(() {
_bulletColor = _getColorFromName(selected);
});
}
Color _getColorFromName(String colorName) {
switch (colorName.toLowerCase()) {
case 'blue':
return Colors.blue;
case 'green':
return Colors.green;
case 'yellow':
return Colors.yellow;
case 'purple':
return Colors.purple;
case 'cyan':
return Colors.cyan;
case 'teal':
return Colors.teal;
case 'indigo':
return Colors.indigo;
case 'pink':
return Colors.pink;
case 'lime':
return Colors.lime;
case 'mint':
return Colors.greenAccent;
case 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
case 'amber':
return Colors.amber;
case 'brown':
return Colors.brown;
case 'grey':
return Colors.grey;
default:
return Colors.blue; // fallback
}
}
void _setSystemBarStyle() {
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.grey[900], // ✅ match your AppBar
statusBarIconBrightness: Brightness.light, // white icons
systemNavigationBarColor: Colors.grey[900],
systemNavigationBarIconBrightness: Brightness.light,
),
);
}
Future<void> _loadSelectedFilter() async {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getInt('selectedFilter');
if (index != null && index >= 0 && index < ImageFilterType.values.length) {
setState(() {
_selectedFilter = ImageFilterType.values[index];
});
}
}
Future<void> _keepScreenAwake() async {
try {
await WakelockPlus.enable();
} catch (e) {
debugPrint("⚠️ Could not keep screen awake: $e");
}
}
Future<void> _loadStreamUrl() async {
final prefs = await SharedPreferences.getInstance();
String? url = prefs.getString('cameraIP_${widget.cameraIndex}');
setState(() {
_streamUrl = (url != null && url.trim().isNotEmpty)
? (url.startsWith(RegExp(r"https?://")) ? url : "http://$url")
: null;
_isError = _streamUrl == null;
});
}
Future<void> _loadBulletCount() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = prefs.getInt('bulletCount') ?? 5;
});
}
Future<void> _captureAndPreviewImage() async {
if (_streamUrl == null || _isCapturing) return;
setState(() => _isCapturing = true);
_originalMatrix = _transformationController.value;
try {
final targetMatrix = Matrix4.identity()..scale(1.0);
final isZoomedIn = !_matrixEquals(_originalMatrix!, targetMatrix);
if (isZoomedIn) {
_zoomAnimController.reset();
_zoomAnimation = Matrix4Tween(
begin: _originalMatrix,
end: targetMatrix,
).animate(CurvedAnimation(
parent: _zoomAnimController,
curve: Curves.easeOut,
));
void updateZoom() {
_transformationController.value = _zoomAnimation!.value;
}
_zoomAnimController.addListener(updateZoom);
await _zoomAnimController.forward();
_zoomAnimController.removeListener(updateZoom);
}
await Future.delayed(const Duration(milliseconds: 100));
final imageBytes = await _screenshotController.capture();
if (imageBytes == null || imageBytes.isEmpty) return;
final decoded = img.decodeImage(imageBytes);
if (decoded == null) return;
final width = decoded.width;
final height = decoded.height;
final size = width < height ? width : height;
final offsetX = (width - size) ~/ 2;
final offsetY = (height - size) ~/ 2;
final cropped = img.copyCrop(decoded, x: offsetX, y: offsetY, width: size, height: size);
// Debugging: log the selected filter
debugPrint("📸 Selected filter: $_selectedFilter");
// Create the output directory and path
final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/ip_capture_temp.jpg';
img.Image processed = cropped;
// Apply the selected filter
switch (_selectedFilter) {
case ImageFilterType.greyscale:
processed = img.grayscale(cropped);
break;
case ImageFilterType.invert:
processed = invertImage(cropped); // ✅ use your custom invert function
break;
case ImageFilterType.none:
break;
}
// Save the processed image to a file
final file = File(path)..writeAsBytesSync(img.encodeJpg(processed));
// Detect objects in the image using YOLO
final detections = await _yoloDetector.detect(file);
if (!mounted) return;
// Navigate to the preview screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PreviewScreen(
imagePath: file.path,
detections: detections,
bulletCount: _numberOfBullets,
),
),
);
} catch (e) {
debugPrint("❌ Capture error: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Capture failed: $e")),
);
}
} finally {
_zoomAnimController.reset();
_zoomAnimation = Matrix4Tween(
begin: _transformationController.value,
end: _originalMatrix!,
).animate(CurvedAnimation(
parent: _zoomAnimController,
curve: Curves.easeIn,
));
void restoreZoom() {
_transformationController.value = _zoomAnimation!.value;
}
_zoomAnimController.addListener(restoreZoom);
await _zoomAnimController.forward();
_zoomAnimController.removeListener(restoreZoom);
setState(() => _isCapturing = false);
}
}
img.Image invertImage(img.Image source) {
final result = img.Image.from(source);
for (int y = 0; y < result.height; y++) {
for (int x = 0; x < result.width; x++) {
final pixel = result.getPixel(x, y);
result.setPixelRgb(x, y, 255 - pixel.r, 255 - pixel.g, 255 - pixel.b);
}
}
return result;
}
bool _matrixEquals(Matrix4 a, Matrix4 b) {
for (int i = 0; i < 16; i++) {
if ((a.storage[i] - b.storage[i]).abs() > 0.001) return false;
}
return true;
}
void _showBulletDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0), // Increased padding for better spacing
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_t('Select the number of bullets'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Center(
child: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: List.generate(5, (index) {
final bulletNum = index + 1;
final isSelected = _numberOfBullets == bulletNum;
return ElevatedButton(
onPressed: () {
_selectBulletCount(bulletNum);
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor:
isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'$bulletNum',
style: TextStyle(
fontSize: 18,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
}),
),
),
],
),
),
);
},
);
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_t('Choose a Filter'),
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: ImageFilterType.values.map((f) {
final isSelected = _selectedFilter == f;
final label = f.toString().split('.').last;
final translatedLabel = _t(label[0].toUpperCase() + label.substring(1));
return ElevatedButton(
onPressed: () async {
setState(() {
_selectedFilter = f;
});
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('selectedFilter', f.index); // Save filter index
if (context.mounted) {
Navigator.of(context).pop();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
),
child: Text(translatedLabel),
);
}).toList(),
),
],
),
),
);
},
);
}
void _selectBulletCount(int number) async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = number;
});
await prefs.setInt('bulletCount', number);
}
void _showHelpDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_t('Help'),
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
_buildHelpItem(Icons.camera_alt, _t('Tap camera button to capture and detect bullets')),
const SizedBox(height: 12),
_buildHelpItem(Icons.filter_9_plus, _t('Bottom left number: Set expected bullet count (1-5)')),
const SizedBox(height: 12),
_buildHelpItem(Icons.brightness_6, _t('Sun icon: Adjust brightness and contrast for better visibility')),
const SizedBox(height: 12),
_buildHelpItem(Icons.filter_alt, _t('Filter icon: Apply color filters (None, Greyscale, Invert)')),
const SizedBox(height: 12),
_buildHelpItem(Icons.pinch, _t('Pinch to zoom on the camera stream')),
const SizedBox(height: 20),
Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
foregroundColor: Colors.white,
),
child: Text(_t('Got it!')),
),
),
],
),
),
);
},
);
}
Widget _buildHelpItem(IconData icon, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: _bulletColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
],
);
}
void _showBrightnessContrastDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setDialogState) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_t('Brightness & Contrast'),
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
// Brightness slider
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_t('Brightness')}: ${(_brightness * 100).toInt()}',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
Slider(
value: _brightness,
min: -1.0,
max: 1.0,
divisions: 40,
activeColor: _bulletColor,
onChanged: (value) {
setState(() {
_brightness = value;
});
setDialogState(() {});
_saveBrightnessContrast();
},
),
],
),
const SizedBox(height: 16),
// Contrast slider
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_t('Contrast')}: ${(_contrast * 100).toInt()}%',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
Slider(
value: _contrast,
min: 0.5,
max: 2.0,
divisions: 30,
activeColor: _bulletColor,
onChanged: (value) {
setState(() {
_contrast = value;
});
setDialogState(() {});
_saveBrightnessContrast();
},
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
setState(() {
_brightness = 0.0;
_contrast = 1.0;
});
setDialogState(() {});
_saveBrightnessContrast();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
),
child: Text(_t('Reset')),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
foregroundColor: Colors.white,
),
child: Text(_t('Done')),
),
],
),
],
),
),
);
},
);
},
);
}
@override
void dispose() {
_zoomAnimController.dispose();
_yoloDetector.close();
_restoreScreenSettings();
_setSystemBarStyle(); // restore grey[900] on back
super.dispose();
}
Future<void> _restoreScreenSettings() async {
try {
await WakelockPlus.disable();
} catch (e) {
debugPrint("⚠️ Could not restore screen settings: $e");
}
}
ColorFilter _buildColorFilter() {
// Base filter matrix
List<double> baseMatrix;
switch (_selectedFilter) {
case ImageFilterType.greyscale:
baseMatrix = [
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
];
break;
case ImageFilterType.invert:
baseMatrix = [
-1.0, 0.0, 0.0, 0.0, 255.0,
0.0, -1.0, 0.0, 0.0, 255.0,
0.0, 0.0, -1.0, 0.0, 255.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
break;
default:
baseMatrix = [
1.0, 0.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
}
// Apply brightness and contrast adjustments
final b = _brightness * 255.0; // Convert to 0-255 range
final c = _contrast;
// Combine filter with brightness/contrast
final adjustedMatrix = <double>[
c, 0.0, 0.0, 0.0, b,
0.0, c, 0.0, 0.0, b,
0.0, 0.0, c, 0.0, b,
0.0, 0.0, 0.0, 1.0, 0.0,
];
// Multiply base matrix with adjustment matrix
return ColorFilter.matrix(_multiplyMatrices(baseMatrix, adjustedMatrix));
}
List<double> _multiplyMatrices(List<double> a, List<double> b) {
// For simplicity, just apply brightness/contrast on top of base filter
final result = <double>[];
for (int i = 0; i < a.length; i++) {
result.add(a[i]);
}
// Apply contrast to RGB components
result[0] = result[0] * b[0]; // R
result[6] = result[6] * b[6]; // G
result[12] = result[12] * b[12]; // B
// Apply brightness
result[4] = result[4] + b[4]; // R offset
result[9] = result[9] + b[9]; // G offset
result[14] = result[14] + b[14]; // B offset
return result;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("${_t('Camera')} ${widget.cameraIndex}"),
backgroundColor: Colors.grey[900],
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.help_outline, color: Colors.white),
onPressed: _showHelpDialog,
),
const SizedBox(width: 16),
],
),
backgroundColor: Colors.black,
body: Stack(
children: [
Screenshot(
controller: _screenshotController,
child: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: (_isError || _streamUrl == null)
? _buildErrorContent()
: InteractiveViewer(
clipBehavior: Clip.hardEdge,
constrained: false,
boundaryMargin: EdgeInsets.zero,
minScale: 1.0,
maxScale: 5.0,
transformationController: _transformationController,
child: SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: RepaintBoundary(
child: ColorFiltered(
colorFilter: _buildColorFilter(),
child: Mjpeg(
key: ValueKey(_streamUrl),
stream: _streamUrl!,
isLive: true,
fit: BoxFit.contain,
error: (context, error, stackTrace) {
// Mark as error so we can display error placeholder outside filters
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_isError = true;
});
}
});
return SizedBox.expand(
child: Container(color: Colors.black),
);
},
),
),
),
),
),
);
},
),
),
if (_isCapturing)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 16),
Text(
_t("Processing image..."),
style: const TextStyle(color: Colors.white, fontSize: 16),
),
],
),
),
),
],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: _isError
? null
: FloatingActionButton(
backgroundColor: Colors.white,
onPressed: _captureAndPreviewImage,
child: const Icon(Icons.camera_alt, color: Colors.black),
),
bottomNavigationBar: BottomAppBar(
color: Colors.grey[900],
shape: const CircularNotchedRectangle(),
notchMargin: 8.0,
child: SizedBox(
height: 60,
child: Row(
children: [
// Left - Bullet count (just number)
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: InkWell(
onTap: _showBulletDialog,
child: Text(
'$_numberOfBullets',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
),
// Center space for FAB
const SizedBox(width: 80),
// Right section - Brightness and Filter
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.brightness_6, color: Colors.white, size: 24),
onPressed: _showBrightnessContrastDialog,
),
IconButton(
icon: const Icon(Icons.filter_alt, color: Colors.white, size: 24),
onPressed: _showFilterDialog,
),
],
),
),
],
),
),
),
);
}
Widget _buildErrorContent() {
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error, color: _bulletColor, size: 100),
const SizedBox(height: 24),
Text(
_t("Unable to load camera stream"),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_t("Please ensure:"),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.wifi, color: Colors.white, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
_t("WiFi is enabled"),
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.signal_cellular_off, color: Colors.white, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
_t("Mobile data is disabled"),
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
],
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _loadStreamUrl,
icon: const Icon(Icons.refresh, size: 20),
label: Text(_t("Reload")),
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
),
),
),
);
}
}