media_reduce 0.1.0
media_reduce: ^0.1.0 copied to clipboard
Iteratively compresses images and videos toward a maximum byte budget on Android and iOS, so files fit API upload limits before sending.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:media_reduce/media_reduce.dart';
import 'package:video_player/video_player.dart';
void main() {
runApp(const MediaReduceDemoApp());
}
class MediaReduceDemoApp extends StatelessWidget {
const MediaReduceDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'media_reduce demo',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const DemoHomePage(),
);
}
}
class DemoHomePage extends StatefulWidget {
const DemoHomePage({super.key});
@override
State<DemoHomePage> createState() => _DemoHomePageState();
}
class _DemoHomePageState extends State<DemoHomePage> {
final MediaReduce _plugin = MediaReduce();
final ImagePicker _picker = ImagePicker();
final TextEditingController _mbController = TextEditingController(text: '4');
File? _file;
bool _isVideo = false;
String? _status;
ReductionResult? _last;
@override
void dispose() {
_mbController.dispose();
super.dispose();
}
Future<void> _pick(ImageSource source, {required bool video}) async {
setState(() {
_status = null;
_last = null;
});
try {
if (video) {
final x = await _picker.pickVideo(source: source);
if (x == null) {
return;
}
setState(() {
_file = File(x.path);
_isVideo = true;
});
} else {
final x = await _picker.pickImage(source: source);
if (x == null) {
return;
}
setState(() {
_file = File(x.path);
_isVideo = false;
});
}
} catch (e) {
setState(() => _status = 'Picker error: $e');
}
}
Future<void> _compress() async {
final file = _file;
if (file == null) {
return;
}
final mb = double.tryParse(_mbController.text.trim());
if (mb == null || mb <= 0) {
setState(() => _status = 'Enter a positive max size in MB.');
return;
}
final maxBytes = (mb * 1024 * 1024).round();
setState(() {
_status = 'Compressing…';
_last = null;
});
try {
final result = await _plugin.reduceToMaxBytes(
ReductionRequest(sourcePath: file.path, maxBytes: maxBytes),
onProgress: (attempt, stage, bytes, target) {
if (!mounted) {
return;
}
setState(() {
_status =
'Attempt $attempt — $stage — ${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB '
'(target ≤ ${(target / (1024 * 1024)).toStringAsFixed(2)} MB)';
});
},
);
if (!mounted) {
return;
}
setState(() {
_last = result;
_status = result.success
? (result.achievedTarget
? 'Done: within budget.'
: 'Done: best effort (see details).')
: 'Failed: ${result.message ?? "unknown"}';
});
} catch (e) {
if (!mounted) {
return;
}
setState(() => _status = 'Error: $e');
}
}
String _fmtBytes(int b) {
if (b < 1024) {
return '$b B';
}
if (b < 1024 * 1024) {
return '${(b / 1024).toStringAsFixed(1)} KB';
}
return '${(b / (1024 * 1024)).toStringAsFixed(2)} MB';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('media_reduce example')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _mbController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Max output size (MB)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton(
onPressed: () => _pick(ImageSource.gallery, video: false),
child: const Text('Pick image'),
),
FilledButton(
onPressed: () => _pick(ImageSource.gallery, video: true),
child: const Text('Pick video'),
),
OutlinedButton(
onPressed: () => _pick(ImageSource.camera, video: false),
child: const Text('Camera photo'),
),
OutlinedButton(
onPressed: () => _pick(ImageSource.camera, video: true),
child: const Text('Camera video'),
),
],
),
const SizedBox(height: 16),
if (_file != null)
Text('Selected: ${_file!.path.split(Platform.pathSeparator).last}'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _file == null ? null : _compress,
child: const Text('Compress toward max MB'),
),
const SizedBox(height: 16),
if (_status != null) Text(_status!),
if (_last != null) ...[
const SizedBox(height: 12),
Text(
'Sizes: ${_fmtBytes(_last!.originalBytes)} → ${_fmtBytes(_last!.outputBytes)} '
'(attempts ${_last!.attempts})',
),
Text('Target hit: ${_last!.achievedTarget}'),
if (_isVideo && _file != null && _last!.outputPath != null) ...[
const SizedBox(height: 16),
_VideoComparison(
originalPath: _file!.path,
compressedPath: _last!.outputPath!,
originalBytes: _last!.originalBytes,
compressedBytes: _last!.outputBytes,
),
],
if (!_isVideo && _last!.outputPath != null) ...[
const SizedBox(height: 16),
_ImageComparison(
originalPath: _file!.path,
compressedPath: _last!.outputPath!,
originalBytes: _last!.originalBytes,
compressedBytes: _last!.outputBytes,
),
],
],
],
),
),
);
}
}
class _ImageComparison extends StatelessWidget {
const _ImageComparison({
required this.originalPath,
required this.compressedPath,
required this.originalBytes,
required this.compressedBytes,
});
final String originalPath;
final String compressedPath;
final int originalBytes;
final int compressedBytes;
String _fmt(int b) {
if (b < 1024 * 1024) {
return '${(b / 1024).toStringAsFixed(1)} KB';
}
return '${(b / (1024 * 1024)).toStringAsFixed(2)} MB';
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Original (${_fmt(originalBytes)})',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Image.file(File(originalPath), fit: BoxFit.contain, height: 200),
const SizedBox(height: 12),
Text('Compressed (${_fmt(compressedBytes)})',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Image.file(File(compressedPath), fit: BoxFit.contain, height: 200),
],
);
}
}
class _VideoComparison extends StatefulWidget {
const _VideoComparison({
required this.originalPath,
required this.compressedPath,
required this.originalBytes,
required this.compressedBytes,
});
final String originalPath;
final String compressedPath;
final int originalBytes;
final int compressedBytes;
@override
State<_VideoComparison> createState() => _VideoComparisonState();
}
class _VideoComparisonState extends State<_VideoComparison> {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LabeledVideo(
label: 'Original (${_fmt(widget.originalBytes)})',
path: widget.originalPath,
),
const SizedBox(height: 16),
_LabeledVideo(
label: 'Compressed (${_fmt(widget.compressedBytes)})',
path: widget.compressedPath,
),
],
);
}
static String _fmt(int b) {
if (b < 1024 * 1024) {
return '${(b / 1024).toStringAsFixed(1)} KB';
}
return '${(b / (1024 * 1024)).toStringAsFixed(2)} MB';
}
}
class _LabeledVideo extends StatefulWidget {
const _LabeledVideo({required this.label, required this.path});
final String label;
final String path;
@override
State<_LabeledVideo> createState() => _LabeledVideoState();
}
class _LabeledVideoState extends State<_LabeledVideo> {
late VideoPlayerController _controller;
bool _ready = false;
String? _error;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.file(File(widget.path))
..initialize().then((_) {
if (!mounted) return;
setState(() => _ready = true);
}).catchError((Object e) {
if (!mounted) return;
setState(() => _error = e.toString());
});
}
@override
void didUpdateWidget(covariant _LabeledVideo oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.path != widget.path) {
_controller.dispose();
_ready = false;
_error = null;
_controller = VideoPlayerController.file(File(widget.path))
..initialize().then((_) {
if (!mounted) return;
setState(() => _ready = true);
}).catchError((Object e) {
if (!mounted) return;
setState(() => _error = e.toString());
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget body;
if (_error != null) {
body = AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.black12,
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text('Cannot play: $_error',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12)),
),
),
);
} else if (!_ready) {
body = const AspectRatio(
aspectRatio: 16 / 9,
child: Center(child: CircularProgressIndicator()),
);
} else {
body = AspectRatio(
aspectRatio: _controller.value.aspectRatio == 0
? 16 / 9
: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
_controller.value.isPlaying
? _controller.pause()
: _controller.play();
});
},
child: Center(
child: AnimatedOpacity(
opacity: _controller.value.isPlaying ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Container(
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(12),
child: const Icon(Icons.play_arrow,
color: Colors.white, size: 32),
),
),
),
),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.label, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
body,
],
);
}
}