add plugin camera desktop

This commit is contained in:
kyo
2026-06-22 09:39:31 +07:00
parent 92daeef0c5
commit 5407afbd6e
144 changed files with 15900 additions and 1 deletions

View 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),
),
);
}
}

View 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});
}

View 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),
),
],
),
),
),
);
},
),
),
);
}
}

View 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();
}
}

View 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),
);
},
),
],
),
);
}
}