add plugin camera desktop
This commit is contained in:
155
plugins/camera_desktop/example/lib/gallery_page.dart
Normal file
155
plugins/camera_desktop/example/lib/gallery_page.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'photo_viewer_page.dart';
|
||||
import 'recent_media.dart';
|
||||
import 'video_player_page.dart';
|
||||
|
||||
class GalleryPage extends StatelessWidget {
|
||||
const GalleryPage({super.key, required this.items});
|
||||
|
||||
final List<MediaEntry> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'Gallery (${items.length} of ${RecentMediaStore.maxItems})',
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildBanner(context),
|
||||
Expanded(
|
||||
child: items.isEmpty ? _buildEmptyState() : _buildGrid(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBanner(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'STORES MOST RECENT ${RecentMediaStore.maxItems} ITEMS ONLY',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.photo_library_outlined, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No photos or videos yet',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final crossAxisCount = width > 900
|
||||
? 4
|
||||
: width > 600
|
||||
? 3
|
||||
: 2;
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _onItemTap(context, item, index),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: item.isVideo
|
||||
? _buildVideoThumbnail()
|
||||
: _buildPhotoThumbnail(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemTap(BuildContext context, MediaEntry item, int index) {
|
||||
if (item.isVideo) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => VideoPlayerPage(path: item.path)),
|
||||
);
|
||||
} else {
|
||||
// Collect only photo entries and find the adjusted index.
|
||||
final photoItems = items.where((e) => e.isPhoto).toList();
|
||||
final photoIndex = photoItems.indexOf(item);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PhotoViewerPage(
|
||||
items: photoItems,
|
||||
initialIndex: photoIndex >= 0 ? photoIndex : 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVideoThumbnail() {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(color: Colors.grey.shade800),
|
||||
const Center(
|
||||
child: Icon(Icons.play_circle_fill, size: 48, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoThumbnail(MediaEntry item) {
|
||||
return Image.file(
|
||||
File(item.path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 300,
|
||||
errorBuilder: (_, error, stackTrace) => Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.broken_image, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
719
plugins/camera_desktop/example/lib/main.dart
Normal file
719
plugins/camera_desktop/example/lib/main.dart
Normal file
@@ -0,0 +1,719 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import 'gallery_page.dart';
|
||||
import 'photo_viewer_page.dart';
|
||||
import 'recent_media.dart';
|
||||
import 'video_player_page.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
runApp(const CameraExampleApp());
|
||||
}
|
||||
|
||||
class CameraExampleApp extends StatelessWidget {
|
||||
const CameraExampleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Camera Desktop Example',
|
||||
// Workaround for an upstream Flutter Linux freeze (flutter/flutter#153560,
|
||||
// still open): the GTK/AT-SPI accessibility bridge blocks the UI thread on
|
||||
// a synchronous D-Bus call while walking the semantics tree when an
|
||||
// AT-SPI client is active (e.g. on settings changes here). Wrapping the
|
||||
// Navigator in ExcludeSemantics prunes the app's accessibility tree,
|
||||
// including overlay routes like dropdown popups, which avoids the hang.
|
||||
// Trade-off: in-app screen-reader support is disabled. Remove this once
|
||||
// the upstream bug is fixed if you need accessibility.
|
||||
builder: (context, child) => ExcludeSemantics(child: child!),
|
||||
home: const CameraExamplePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum CaptureMode { photo, video }
|
||||
|
||||
const List<int> kFpsOptions = [15, 24, 30, 60];
|
||||
|
||||
const List<({int value, String label})> kVideoBitrateOptions = [
|
||||
(value: 1000000, label: '1 Mbps'),
|
||||
(value: 2500000, label: '2.5 Mbps'),
|
||||
(value: 5000000, label: '5 Mbps'),
|
||||
(value: 10000000, label: '10 Mbps'),
|
||||
(value: 20000000, label: '20 Mbps'),
|
||||
];
|
||||
|
||||
const List<({int value, String label})> kAudioBitrateOptions = [
|
||||
(value: 64000, label: '64 kbps'),
|
||||
(value: 128000, label: '128 kbps'),
|
||||
(value: 192000, label: '192 kbps'),
|
||||
(value: 256000, label: '256 kbps'),
|
||||
];
|
||||
|
||||
class CameraExamplePage extends StatefulWidget {
|
||||
const CameraExamplePage({super.key});
|
||||
|
||||
@override
|
||||
State<CameraExamplePage> createState() => _CameraExamplePageState();
|
||||
}
|
||||
|
||||
class _CameraExamplePageState extends State<CameraExamplePage> {
|
||||
CameraController? _controller;
|
||||
List<CameraDescription> _cameras = [];
|
||||
String? _errorMessage;
|
||||
bool _isInitialized = false;
|
||||
bool _isCapturing = false;
|
||||
|
||||
CaptureMode _mode = CaptureMode.photo;
|
||||
bool _isRecording = false;
|
||||
bool _isStoppingRecording = false;
|
||||
Duration _recordingDuration = Duration.zero;
|
||||
Timer? _recordingTimer;
|
||||
int _nextPendingVideoId = 1;
|
||||
final List<_PendingVideo> _pendingVideos = <_PendingVideo>[];
|
||||
|
||||
// Settings state, these drive the CameraController constructor.
|
||||
int _selectedCameraIndex = 0;
|
||||
ResolutionPreset _resolutionPreset = ResolutionPreset.veryHigh;
|
||||
int _fps = 30;
|
||||
int _videoBitrate = 5000000;
|
||||
int _audioBitrate = 128000;
|
||||
bool _enableAudio = true;
|
||||
bool _showSettings = false;
|
||||
bool _isReinitializing = false;
|
||||
|
||||
final RecentMediaStore _mediaStore = RecentMediaStore();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initCamera();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
try {
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras.isEmpty) {
|
||||
setState(() => _errorMessage = 'No cameras found');
|
||||
return;
|
||||
}
|
||||
await _createAndInitController();
|
||||
} on CameraException catch (e) {
|
||||
setState(() => _errorMessage = 'Camera error: ${e.description}');
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = 'Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createAndInitController() async {
|
||||
final controller = CameraController(
|
||||
_cameras[_selectedCameraIndex],
|
||||
_resolutionPreset,
|
||||
enableAudio: _enableAudio,
|
||||
fps: _fps,
|
||||
videoBitrate: _videoBitrate,
|
||||
audioBitrate: _audioBitrate,
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_controller = controller;
|
||||
_isInitialized = true;
|
||||
_isReinitializing = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reinitCamera() async {
|
||||
if (_isRecording || _isStoppingRecording) return;
|
||||
|
||||
setState(() {
|
||||
_isReinitializing = true;
|
||||
_isInitialized = false;
|
||||
});
|
||||
|
||||
await _controller?.dispose();
|
||||
_controller = null;
|
||||
|
||||
try {
|
||||
await _createAndInitController();
|
||||
} on CameraException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isReinitializing = false;
|
||||
_errorMessage = 'Camera error: ${e.description}';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isReinitializing = false;
|
||||
_errorMessage = 'Error: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _takePicture() async {
|
||||
if (_controller == null || !_isInitialized || _isCapturing) return;
|
||||
setState(() => _isCapturing = true);
|
||||
try {
|
||||
final file = await _controller!.takePicture();
|
||||
if (!mounted) return;
|
||||
_mediaStore.add(file.path, MediaType.photo);
|
||||
setState(() => _errorMessage = null);
|
||||
} on CameraException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Capture failed: ${e.description}')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isCapturing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
if (_controller == null || !_isInitialized || _isRecording) return;
|
||||
try {
|
||||
await _controller!.startVideoRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_recordingDuration = Duration.zero;
|
||||
});
|
||||
_recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) {
|
||||
setState(() => _recordingDuration += const Duration(seconds: 1));
|
||||
}
|
||||
});
|
||||
} on CameraException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Recording failed: ${e.description}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording() async {
|
||||
if (!_isRecording || _isStoppingRecording || _controller == null) return;
|
||||
_recordingTimer?.cancel();
|
||||
final pendingId = _nextPendingVideoId++;
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
_isStoppingRecording = true;
|
||||
_pendingVideos.insert(0, _PendingVideo(id: pendingId));
|
||||
});
|
||||
|
||||
unawaited(_finalizeVideoStop(pendingId));
|
||||
}
|
||||
|
||||
Future<void> _finalizeVideoStop(int pendingId) async {
|
||||
try {
|
||||
final file = await _controller!.stopVideoRecording();
|
||||
if (!mounted) return;
|
||||
_mediaStore.add(file.path, MediaType.video);
|
||||
setState(() {
|
||||
_isStoppingRecording = false;
|
||||
_pendingVideos.removeWhere((p) => p.id == pendingId);
|
||||
});
|
||||
} on CameraException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isStoppingRecording = false;
|
||||
_pendingVideos.removeWhere((p) => p.id == pendingId);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Stop recording failed: ${e.description}')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isStoppingRecording = false;
|
||||
_pendingVideos.removeWhere((p) => p.id == pendingId);
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Stop recording failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimer(Duration d) {
|
||||
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recordingTimer?.cancel();
|
||||
_controller?.dispose();
|
||||
_mediaStore.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Camera Desktop Example'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showSettings ? Icons.settings : Icons.settings_outlined,
|
||||
),
|
||||
tooltip: 'Settings',
|
||||
onPressed: _isRecording
|
||||
? null
|
||||
: () => setState(() => _showSettings = !_showSettings),
|
||||
),
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
label: Text('${_mediaStore.count + _pendingVideos.length}'),
|
||||
isLabelVisible:
|
||||
_mediaStore.isNotEmpty || _pendingVideos.isNotEmpty,
|
||||
child: const Icon(Icons.photo_library),
|
||||
),
|
||||
tooltip:
|
||||
'Gallery (${_mediaStore.count + _pendingVideos.length}/${RecentMediaStore.maxItems})',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => GalleryPage(items: _mediaStore.items),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: _buildPreview()),
|
||||
if (_showSettings && _isInitialized) _buildSettingsPanel(),
|
||||
if (_isInitialized) _buildControlBar(),
|
||||
if (_mediaStore.isNotEmpty || _pendingVideos.isNotEmpty)
|
||||
_buildThumbnailStrip(),
|
||||
if (_errorMessage != null && !_isInitialized)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview() {
|
||||
if (_errorMessage != null && !_isInitialized && !_isReinitializing) {
|
||||
return Center(child: Text(_errorMessage!));
|
||||
}
|
||||
if (!_isInitialized || _controller == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Center(child: CameraPreview(_controller!)),
|
||||
if (_isRecording)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fiber_manual_record,
|
||||
color: Colors.red,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'REC ${_formatTimer(_recordingDuration)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _resolutionLabel(ResolutionPreset preset) {
|
||||
return switch (preset) {
|
||||
ResolutionPreset.low => 'Low (240p)',
|
||||
ResolutionPreset.medium => 'Medium (480p)',
|
||||
ResolutionPreset.high => 'High (720p)',
|
||||
ResolutionPreset.veryHigh => 'Very High (1080p)',
|
||||
ResolutionPreset.ultraHigh => 'Ultra High (4K)',
|
||||
ResolutionPreset.max => 'Max',
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildSettingsPanel() {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 24,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
if (_cameras.length > 1)
|
||||
_buildDropdownSetting<int>(
|
||||
label: 'Camera',
|
||||
value: _selectedCameraIndex,
|
||||
items: [
|
||||
for (var i = 0; i < _cameras.length; i++)
|
||||
DropdownMenuItem(value: i, child: Text(_cameras[i].name)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v == null || v == _selectedCameraIndex) return;
|
||||
setState(() => _selectedCameraIndex = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
_buildDropdownSetting<ResolutionPreset>(
|
||||
label: 'Resolution',
|
||||
value: _resolutionPreset,
|
||||
items: ResolutionPreset.values
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p,
|
||||
child: Text(_resolutionLabel(p)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null || v == _resolutionPreset) return;
|
||||
setState(() => _resolutionPreset = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
_buildDropdownSetting<int>(
|
||||
label: 'FPS',
|
||||
value: _fps,
|
||||
items: kFpsOptions
|
||||
.map((f) => DropdownMenuItem(value: f, child: Text('$f fps')))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null || v == _fps) return;
|
||||
setState(() => _fps = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
_buildDropdownSetting<int>(
|
||||
label: 'Video Bitrate',
|
||||
value: _videoBitrate,
|
||||
items: kVideoBitrateOptions
|
||||
.map(
|
||||
(o) => DropdownMenuItem(value: o.value, child: Text(o.label)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null || v == _videoBitrate) return;
|
||||
setState(() => _videoBitrate = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
_buildDropdownSetting<int>(
|
||||
label: 'Audio Bitrate',
|
||||
value: _audioBitrate,
|
||||
items: kAudioBitrateOptions
|
||||
.map(
|
||||
(o) => DropdownMenuItem(value: o.value, child: Text(o.label)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null || v == _audioBitrate) return;
|
||||
setState(() => _audioBitrate = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
_buildSwitchSetting(
|
||||
label: 'Audio',
|
||||
value: _enableAudio,
|
||||
onChanged: (v) {
|
||||
setState(() => _enableAudio = v);
|
||||
_reinitCamera();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownSetting<T>({
|
||||
required String label,
|
||||
required T value,
|
||||
required List<DropdownMenuItem<T>> items,
|
||||
required ValueChanged<T?> onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
DropdownButton<T>(
|
||||
value: value,
|
||||
items: items,
|
||||
onChanged: _isReinitializing ? null : onChanged,
|
||||
underline: const SizedBox.shrink(),
|
||||
isDense: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchSetting({
|
||||
required String label,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Switch(value: value, onChanged: _isReinitializing ? null : onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SegmentedButton<CaptureMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: CaptureMode.photo,
|
||||
icon: Icon(Icons.camera_alt),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: CaptureMode.video,
|
||||
icon: Icon(Icons.videocam),
|
||||
),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: _isRecording
|
||||
? null
|
||||
: (selection) => setState(() => _mode = selection.first),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildCaptureButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureButton() {
|
||||
if (_mode == CaptureMode.photo) {
|
||||
return FloatingActionButton(
|
||||
onPressed: _isCapturing ? null : _takePicture,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
child: _isCapturing
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.camera_alt),
|
||||
);
|
||||
}
|
||||
|
||||
// Video mode
|
||||
if (_isRecording) {
|
||||
return FloatingActionButton(
|
||||
onPressed: _stopRecording,
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
child: const Icon(Icons.stop),
|
||||
);
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
onPressed: _isStoppingRecording ? null : _startRecording,
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
child: _isStoppingRecording
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.fiber_manual_record),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnailStrip() {
|
||||
final items = _mediaStore.items;
|
||||
final pendingCount = _pendingVideos.length;
|
||||
return SizedBox(
|
||||
height: 72,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemCount: pendingCount + items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isPending = index < pendingCount;
|
||||
final item = isPending ? null : items[index - pendingCount];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (isPending) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Video is still processing. Please wait.'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_onThumbnailTap(item!, index - pendingCount);
|
||||
}
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: isPending
|
||||
? _buildPendingVideoThumbnail()
|
||||
: item!.isVideo
|
||||
? _buildVideoThumbnail()
|
||||
: _buildPhotoThumbnail(item),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onThumbnailTap(MediaEntry item, int index) {
|
||||
if (item.isVideo) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => VideoPlayerPage(path: item.path)),
|
||||
);
|
||||
} else {
|
||||
final photoItems = _mediaStore.items.where((e) => e.isPhoto).toList();
|
||||
final photoIndex = photoItems.indexOf(item);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PhotoViewerPage(
|
||||
items: photoItems,
|
||||
initialIndex: photoIndex >= 0 ? photoIndex : 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVideoThumbnail() {
|
||||
return Container(
|
||||
color: Colors.grey.shade800,
|
||||
child: const Center(
|
||||
child: Icon(Icons.play_circle_fill, size: 28, color: Colors.white70),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingVideoThumbnail() {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(color: Colors.grey.shade700),
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: const Text(
|
||||
'Processing',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white, fontSize: 9),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoThumbnail(MediaEntry item) {
|
||||
return Image.file(
|
||||
File(item.path),
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 120,
|
||||
errorBuilder: (_, error, stackTrace) => Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
color: Colors.grey.shade300,
|
||||
child: const Icon(Icons.broken_image, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingVideo {
|
||||
final int id;
|
||||
|
||||
_PendingVideo({required this.id});
|
||||
}
|
||||
109
plugins/camera_desktop/example/lib/photo_viewer_page.dart
Normal file
109
plugins/camera_desktop/example/lib/photo_viewer_page.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'recent_media.dart';
|
||||
|
||||
class PhotoViewerPage extends StatefulWidget {
|
||||
const PhotoViewerPage({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.initialIndex,
|
||||
});
|
||||
|
||||
final List<MediaEntry> items;
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
State<PhotoViewerPage> createState() => _PhotoViewerPageState();
|
||||
}
|
||||
|
||||
class _PhotoViewerPageState extends State<PhotoViewerPage> {
|
||||
late final PageController _pageController;
|
||||
late int _currentIndex;
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex.clamp(0, widget.items.length - 1);
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
_focusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
if (_currentIndex > 0) {
|
||||
_pageController.animateToPage(
|
||||
_currentIndex - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
if (_currentIndex < widget.items.length - 1) {
|
||||
_pageController.animateToPage(
|
||||
_currentIndex + 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: Text('Photo ${_currentIndex + 1} of ${widget.items.length}'),
|
||||
backgroundColor: Colors.black54,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.items.length,
|
||||
onPageChanged: (i) => setState(() => _currentIndex = i),
|
||||
itemBuilder: (context, index) {
|
||||
return InteractiveViewer(
|
||||
minScale: 1.0,
|
||||
maxScale: 5.0,
|
||||
child: Center(
|
||||
child: Image.file(
|
||||
File(widget.items[index].path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, error, stackTrace) => const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Photo unavailable',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
plugins/camera_desktop/example/lib/recent_media.dart
Normal file
39
plugins/camera_desktop/example/lib/recent_media.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum MediaType { photo, video }
|
||||
|
||||
class MediaEntry {
|
||||
final String path;
|
||||
final DateTime capturedAt;
|
||||
final MediaType type;
|
||||
|
||||
MediaEntry({
|
||||
required this.path,
|
||||
required this.capturedAt,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
bool get isVideo => type == MediaType.video;
|
||||
bool get isPhoto => type == MediaType.photo;
|
||||
}
|
||||
|
||||
class RecentMediaStore extends ChangeNotifier {
|
||||
static const int maxItems = 10;
|
||||
final List<MediaEntry> _items = [];
|
||||
|
||||
List<MediaEntry> get items => List.unmodifiable(_items);
|
||||
int get count => _items.length;
|
||||
bool get isEmpty => _items.isEmpty;
|
||||
bool get isNotEmpty => _items.isNotEmpty;
|
||||
|
||||
void add(String path, MediaType type) {
|
||||
_items.insert(
|
||||
0,
|
||||
MediaEntry(path: path, capturedAt: DateTime.now(), type: type),
|
||||
);
|
||||
if (_items.length > maxItems) {
|
||||
_items.removeLast();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
161
plugins/camera_desktop/example/lib/video_player_page.dart
Normal file
161
plugins/camera_desktop/example/lib/video_player_page.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
class VideoPlayerPage extends StatefulWidget {
|
||||
const VideoPlayerPage({super.key, required this.path});
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
|
||||
}
|
||||
|
||||
class _VideoPlayerPageState extends State<VideoPlayerPage> {
|
||||
late final Player _player;
|
||||
late final VideoController _videoController;
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player = Player();
|
||||
_videoController = VideoController(_player);
|
||||
_focusNode = FocusNode();
|
||||
_player.open(Media(widget.path));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
_player.playOrPause();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) {
|
||||
final hours = d.inHours.toString().padLeft(2, '0');
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: const Text('Video'),
|
||||
backgroundColor: Colors.black54,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Platform.isWindows
|
||||
? Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.diagonal3Values(-1, 1, 1),
|
||||
child: Video(
|
||||
controller: _videoController,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Video(controller: _videoController, fit: BoxFit.contain),
|
||||
),
|
||||
_buildControls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Container(
|
||||
color: Colors.black87,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamBuilder<bool>(
|
||||
stream: _player.stream.playing,
|
||||
builder: (context, snap) {
|
||||
final playing = snap.data ?? false;
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
playing ? Icons.pause : Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: _player.playOrPause,
|
||||
);
|
||||
},
|
||||
),
|
||||
StreamBuilder<Duration>(
|
||||
stream: _player.stream.position,
|
||||
builder: (context, snap) {
|
||||
return Text(
|
||||
_formatDuration(snap.data ?? Duration.zero),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: _player.stream.duration,
|
||||
builder: (context, durSnap) {
|
||||
final duration = durSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: _player.stream.position,
|
||||
builder: (context, posSnap) {
|
||||
final position = posSnap.data ?? Duration.zero;
|
||||
return Slider(
|
||||
value: duration.inMilliseconds > 0
|
||||
? position.inMilliseconds
|
||||
.clamp(0, duration.inMilliseconds)
|
||||
.toDouble()
|
||||
: 0,
|
||||
max: duration.inMilliseconds > 0
|
||||
? duration.inMilliseconds.toDouble()
|
||||
: 1,
|
||||
onChanged: (v) {
|
||||
_player.seek(Duration(milliseconds: v.toInt()));
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white24,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
StreamBuilder<Duration>(
|
||||
stream: _player.stream.duration,
|
||||
builder: (context, snap) {
|
||||
return Text(
|
||||
_formatDuration(snap.data ?? Duration.zero),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user