media_reduce 0.1.0 copy "media_reduce: ^0.1.0" to clipboard
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,
      ],
    );
  }
}
0
likes
150
points
10
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Iteratively compresses images and videos toward a maximum byte budget on Android and iOS, so files fit API upload limits before sending.

Homepage

Topics

#media #compression #video #image #upload

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on media_reduce

Packages that implement media_reduce