Files
SDKmuta/lib/screens/stream_screen.dart
T
2025-04-05 11:47:40 +02:00

580 lines
18 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';
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;
late AnimationController _zoomAnimController;
Animation<Matrix4>? _zoomAnimation;
Matrix4? _originalMatrix;
Color _bulletColor = Colors.blue; // default/fallback bullet color
@override
void initState() {
super.initState();
_loadStreamUrl();
_loadBulletCount();
_loadSelectedFilter();
_loadBulletColor(); // 👈 add this to actually load the saved bullet color
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> _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;
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.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16.0),
child: Text(
'Select the number of bullets',
style: 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: [
const Text(
'Choose a Filter',
style: 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;
return ElevatedButton(
onPressed: () async {
setState(() {
_selectedFilter = f;
});
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('selectedFilter', f.index); // Save filter index
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
),
child: Text(label[0].toUpperCase() + label.substring(1)),
);
}).toList(),
),
],
),
),
);
},
);
}
void _selectBulletCount(int number) async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = number;
});
await prefs.setInt('bulletCount', number);
}
@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() {
switch (_selectedFilter) {
case ImageFilterType.greyscale:
return const ColorFilter.matrix([
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,
]);
case ImageFilterType.invert:
return const ColorFilter.matrix([
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]);
default:
return const ColorFilter.mode(Colors.transparent, BlendMode.dst);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Target ${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: Text(
'$_numberOfBullets',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
onPressed: _showBulletDialog,
),
IconButton(
icon: const Icon(Icons.filter_alt, color: Colors.white),
onPressed: _showFilterDialog,
),
const SizedBox(width: 16),
],
),
backgroundColor: Colors.black,
body: Stack(
children: [
Screenshot(
controller: _screenshotController,
child: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: 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: (_isError || _streamUrl == null)
? _buildErrorContent()
: RepaintBoundary(
child: ColorFiltered(
colorFilter: _buildColorFilter(),
child: Mjpeg(
stream: _streamUrl!,
isLive: true,
fit: BoxFit.contain,
error: (context, error, stackTrace) => _buildErrorContent(),
),
),
),
),
),
);
},
),
),
if (_isCapturing)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
"Processing image...",
style: 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: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: Alignment.center,
),
],
),
),
),
);
}
Widget _buildErrorContent() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 100),
const SizedBox(height: 20),
const Text(
"Unable to load camera stream",
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: _loadStreamUrl,
child: const Text("Retry"),
),
],
);
}
}