xcamera 0.0.2
xcamera: ^0.0.2 copied to clipboard
A lightweight Flutter camera plugin for Linux supporting high-performance V4L2 streaming and synchronized H.264/AAC video recording.
example/lib/main.dart
// example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:xcamera/xcamera.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'XCamera Diagnostic Dashboard',
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.dark(
primary: Colors.blueAccent,
secondary: Colors.tealAccent,
surface: Colors.grey[900]!,
),
),
home: const DashboardScreen(),
);
}
}
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
/// List of available cameras on the device.
List<CameraDescription> _cameras = [];
/// Currently selected camera for detail controls/diagnostics.
CameraDescription? _selectedCamera;
/// Selected resolution preset for the camera.
ResolutionPreset _selectedPreset = ResolutionPreset.medium;
/// Map of index to active camera controllers.
final Map<int, CameraController> _controllers = {};
/// Dynamically get the active controller for the currently selected detail camera.
CameraController? get _controller => _controllers[_selectedCamera?.index ?? -1];
/// Status message to display in the UI.
String _statusMessage = 'System Ready';
/// Error message to display when an error occurs.
String _errorMessage = '';
/// Image streaming statistics
int _imageStreamFrameCount = 0;
int _imageStreamWidth = 0;
int _imageStreamHeight = 0;
bool _enableAudio = true;
@override
void initState() {
super.initState();
_loadCamerasAndInitialize();
}
@override
void dispose() {
for (final CameraController controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
/// Loads available cameras and initializes the first one.
Future<void> _loadCamerasAndInitialize() async {
try {
// Get list of available cameras
final cameras = await availableCameras();
// Dispose all existing controllers
for (final CameraController controller in _controllers.values) {
await controller.dispose();
}
_controllers.clear();
setState(() {
_cameras = cameras;
if (cameras.isNotEmpty) {
_selectedCamera = cameras.first;
}
});
// Initialize the first camera if available
if (_selectedCamera != null) {
await _initializeCamera();
} else {
setState(() => _errorMessage = 'No camera devices detected.');
}
} catch (e) {
setState(() => _errorMessage = 'Failed to load cameras: $e');
}
}
/// Initializes the camera controller with the selected camera and preset.
Future<void> _initializeCamera() async {
if (_selectedCamera == null) return;
setState(() {
_errorMessage = '';
_statusMessage = 'Initializing camera ${_selectedCamera!.name}...';
});
// Dispose existing controller for this camera if any
final existing = _controllers[_selectedCamera!.index];
if (existing != null) {
await existing.dispose();
_controllers.remove(_selectedCamera!.index);
}
try {
// Create new camera controller
final controller = CameraController(_selectedCamera!, _selectedPreset);
// Listen to value changes for UI updates
controller.addListener(() {
if (mounted) setState(() {});
});
// Initialize the controller
await controller.initialize();
setState(() {
_controllers[_selectedCamera!.index] = controller;
_statusMessage = 'Camera ${_selectedCamera!.name} initialized successfully';
});
} catch (e) {
setState(() {
_errorMessage = e is CameraException ? e.message : e.toString();
_statusMessage = 'Initialization failed';
});
}
}
// Camera Action Methods
/// Captures a photo and saves it to the device.
Future<void> _takePhoto() async {
if (_controller == null || !_controller!.value.isInitialized) return;
try {
setState(() => _statusMessage = 'Capturing snapshot...');
final file = await _controller!.takePicture();
setState(() => _statusMessage = 'Photo saved: ${file.path}');
} catch (e) {
setState(() => _statusMessage = 'Error taking photo: $e');
}
}
/// Toggles video recording on/off.
Future<void> _toggleVideoRecording() async {
if (_controller == null || !_controller!.value.isInitialized) return;
try {
if (_controller!.value.isRecordingVideo) {
// Stop recording
setState(() => _statusMessage = 'Stopping recording...');
final file = await _controller!.stopVideoRecording();
setState(() => _statusMessage = 'Video saved: ${file.path}');
} else {
// Start recording
setState(() => _statusMessage = 'Starting recording...');
await _controller!.startVideoRecording(enableAudio: _enableAudio);
setState(() => _statusMessage = 'Recording in progress...');
}
} catch (e) {
setState(() => _statusMessage = 'Recording error: $e');
}
}
/// Toggles pause/resume for video recording.
Future<void> _togglePauseRecording() async {
if (_controller == null || !_controller!.value.isInitialized || !_controller!.value.isRecordingVideo) {
return;
}
try {
if (_controller!.value.isRecordingPaused) {
// Resume recording
setState(() => _statusMessage = 'Resuming recording...');
await _controller!.resumeVideoRecording();
setState(() => _statusMessage = 'Recording resumed');
} else {
// Pause recording
setState(() => _statusMessage = 'Pausing recording...');
await _controller!.pauseVideoRecording();
setState(() => _statusMessage = 'Recording paused');
}
} catch (e) {
setState(() => _statusMessage = 'Recording pause/resume error: $e');
}
}
/// Toggles pause/resume for the camera preview.
Future<void> _togglePausePreview() async {
if (_controller == null || !_controller!.value.isInitialized) return;
try {
if (_controller!.value.isPreviewPaused) {
// Resume preview
setState(() => _statusMessage = 'Resuming preview...');
await _controller!.resumePreview();
setState(() => _statusMessage = 'Preview resumed');
} else {
// Pause preview
setState(() => _statusMessage = 'Pausing preview...');
await _controller!.pausePreview();
setState(() => _statusMessage = 'Preview paused');
}
} catch (e) {
setState(() => _statusMessage = 'Preview pause/resume error: $e');
}
}
/// Toggles image streaming on/off.
Future<void> _toggleImageStream() async {
if (_controller == null || !_controller!.value.isInitialized) return;
try {
if (_controller!.value.isImageStreaming) {
setState(() => _statusMessage = 'Stopping image stream...');
await _controller!.stopImageStream();
setState(() => _statusMessage = 'Image stream stopped');
} else {
setState(() {
_statusMessage = 'Starting image stream...';
_imageStreamFrameCount = 0;
});
await _controller!.startImageStream((CameraImage image) {
if (mounted) {
setState(() {
_imageStreamFrameCount++;
_imageStreamWidth = image.width;
_imageStreamHeight = image.height;
});
}
});
setState(() => _statusMessage = 'Image stream active');
}
} catch (e) {
setState(() => _statusMessage = 'Image stream error: $e');
}
}
/// Builds a diagnostic row with label and value.
Widget _buildDiagnosticRow(String label, String value, {bool isHighlight = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.grey),
),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isHighlight ? Colors.tealAccent : Colors.white,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('XCamera Diagnostic Dashboard'),
backgroundColor: Theme.of(context).colorScheme.surface,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadCamerasAndInitialize,
tooltip: 'Reload cameras',
),
],
),
body: Row(
children: [
Expanded(
flex: 3,
child: Container(
color: Colors.black,
child: _buildPreviewContent(),
),
),
Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: const Border(left: BorderSide(color: Colors.black54, width: 2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusBar(),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceSettingsSection(),
const SizedBox(height: 24),
_buildControllerStateSection(),
const SizedBox(height: 24),
_buildControlsSection(),
],
),
),
],
),
),
),
],
),
);
}
/// Builds the preview content (camera preview grid or error state).
Widget _buildPreviewContent() {
if (_errorMessage.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Center(
child: Text(
_errorMessage,
style: const TextStyle(color: Colors.redAccent, fontSize: 16),
textAlign: TextAlign.center,
),
),
);
}
if (_cameras.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.25,
),
itemCount: _cameras.length,
itemBuilder: (context, index) {
final camera = _cameras[index];
final controller = _controllers[camera.index];
final isSelected = _selectedCamera?.index == camera.index;
return Card(
elevation: isSelected ? 8 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? Colors.tealAccent : Colors.transparent,
width: 2,
),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
setState(() {
_selectedCamera = camera;
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header bar
Container(
color: isSelected ? Colors.teal.withAlpha(50) : Colors.blueAccent.withAlpha(20),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
camera.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: isSelected ? Colors.tealAccent : Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
controller == null ? 'OFFLINE' : (controller.value.isRecordingVideo ? 'RECORDING' : 'LIVE'),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: controller == null ? Colors.grey : (controller.value.isRecordingVideo ? Colors.redAccent : Colors.tealAccent),
),
),
],
),
),
// Camera stream or start button
Expanded(
child: Container(
color: Colors.black87,
child: (() {
if (controller != null && controller.value.isInitialized) {
return Center(
child: AspectRatio(
aspectRatio: controller.value.previewSize != null
? controller.value.previewSize!.width / controller.value.previewSize!.height
: 16 / 9,
child: CameraPreview(controller),
),
);
} else {
return Center(
child: ElevatedButton.icon(
onPressed: () {
setState(() {
_selectedCamera = camera;
});
_initializeCamera();
},
icon: const Icon(Icons.videocam),
label: const Text('Start Stream'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
),
),
);
}
})(),
),
),
// Quick info / control footer
if (controller != null && controller.value.isInitialized) ...[
Container(
padding: const EdgeInsets.all(8),
color: Colors.grey[900],
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.value.previewSize != null
? '${controller.value.previewSize!.width.toInt()}x${controller.value.previewSize!.height.toInt()}'
: 'Unknown',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
if (controller.value.isRecordingVideo) const Icon(Icons.fiber_manual_record, color: Colors.redAccent, size: 14),
],
),
),
],
],
),
),
);
},
);
}
/// Builds the status bar showing current status message.
Widget _buildStatusBar() {
return Container(
color: Colors.blueAccent.withAlpha(31),
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blueAccent, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusMessage,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.blueAccent),
),
),
],
),
);
}
/// Builds the device settings section with camera and resolution selection.
Widget _buildDeviceSettingsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'DEVICE SETTINGS',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blueAccent),
),
const SizedBox(height: 8),
DropdownButtonFormField<CameraDescription>(
decoration: const InputDecoration(
labelText: 'Active Camera',
border: OutlineInputBorder(),
),
initialValue: _selectedCamera,
items: _cameras.map((camera) {
return DropdownMenuItem<CameraDescription>(
value: camera,
child: Text(camera.name),
);
}).toList(),
onChanged: (camera) {
if (camera != null) {
setState(() => _selectedCamera = camera);
_initializeCamera();
}
},
),
const SizedBox(height: 12),
DropdownButtonFormField<ResolutionPreset>(
decoration: const InputDecoration(
labelText: 'Resolution Preset',
border: OutlineInputBorder(),
),
initialValue: _selectedPreset,
items: ResolutionPreset.values.map((preset) {
return DropdownMenuItem<ResolutionPreset>(
value: preset,
child: Text(preset.name.toUpperCase()),
);
}).toList(),
onChanged: (preset) {
if (preset != null) {
setState(() => _selectedPreset = preset);
_initializeCamera();
}
},
),
],
);
}
/// Builds the controller state diagnostics section.
Widget _buildControllerStateSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'CONTROLLER STATE',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blueAccent),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
_buildDiagnosticRow(
'Initialized',
_controller?.value.isInitialized.toString() ?? 'false',
),
_buildDiagnosticRow(
'Streaming Images',
_controller?.value.isStreamingImages.toString() ?? 'false',
),
_buildDiagnosticRow(
'Preview Paused',
_controller?.value.isPreviewPaused.toString() ?? 'false',
isHighlight: _controller?.value.isPreviewPaused ?? false,
),
_buildDiagnosticRow(
'Recording Video',
_controller?.value.isRecordingVideo.toString() ?? 'false',
isHighlight: _controller?.value.isRecordingVideo ?? false,
),
_buildDiagnosticRow(
'Recording Paused',
_controller?.value.isRecordingPaused.toString() ?? 'false',
isHighlight: _controller?.value.isRecordingPaused ?? false,
),
_buildDiagnosticRow(
'Resolution Size',
_controller?.value.previewSize != null
? '${_controller!.value.previewSize!.width.toInt()} x ${_controller!.value.previewSize!.height.toInt()}'
: 'Unknown',
),
_buildDiagnosticRow(
'Image Stream Active',
_controller?.value.isImageStreaming.toString() ?? 'false',
isHighlight: _controller?.value.isImageStreaming ?? false,
),
if (_controller?.value.isImageStreaming == true) ...[
_buildDiagnosticRow(
'Stream Frame Count',
_imageStreamFrameCount.toString(),
isHighlight: true,
),
_buildDiagnosticRow(
'Stream Frame Size',
'$_imageStreamWidth x $_imageStreamHeight',
),
],
],
),
),
],
);
}
/// Builds the controls section with action buttons.
Widget _buildControlsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'CONTROLS',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blueAccent),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _controller?.value.isInitialized == true ? _takePhoto : null,
icon: const Icon(Icons.camera_alt),
label: const Text('Capture Snapshot'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 8),
SwitchListTile(
title: const Text('Record Audio'),
value: _enableAudio,
activeThumbColor: Colors.teal,
onChanged: _controller?.value.isRecordingVideo == true ? null : (val) => setState(() => _enableAudio = val),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _controller?.value.isInitialized == true ? _toggleVideoRecording : null,
icon: Icon(_controller?.value.isRecordingVideo == true ? Icons.stop : Icons.videocam),
label: Text(_controller?.value.isRecordingVideo == true ? 'Stop Video' : 'Record Video'),
style: ElevatedButton.styleFrom(
backgroundColor: _controller?.value.isRecordingVideo == true ? Colors.redAccent : Colors.teal,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
),
if (_controller?.value.isRecordingVideo == true) ...[
const SizedBox(width: 8),
IconButton.filled(
onPressed: _togglePauseRecording,
icon: Icon(
_controller?.value.isRecordingPaused == true ? Icons.play_arrow : Icons.pause,
),
style: IconButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
],
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _controller?.value.isInitialized == true ? _togglePausePreview : null,
icon: Icon(_controller?.value.isPreviewPaused == true ? Icons.visibility : Icons.visibility_off),
label: Text(_controller?.value.isPreviewPaused == true ? 'Resume Preview' : 'Pause Preview'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _controller?.value.isInitialized == true ? _toggleImageStream : null,
icon: Icon(_controller?.value.isImageStreaming == true ? Icons.stop_circle : Icons.play_circle),
label: Text(_controller?.value.isImageStreaming == true ? 'Stop Image Stream' : 'Start Image Stream'),
style: ElevatedButton.styleFrom(
backgroundColor: _controller?.value.isImageStreaming == true ? Colors.redAccent : Colors.teal[700],
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
],
);
}
}