ffmpeg_streamer 0.5.0 copy "ffmpeg_streamer: ^0.5.0" to clipboard
ffmpeg_streamer: ^0.5.0 copied to clipboard

A cross-platform Flutter FFI plugin that embeds FFmpeg to decode audio & video and stream raw frames — works on Android, iOS, macOS, Windows & Linux.

example/lib/main.dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:ffmpeg_streamer/ffmpeg_streamer.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';

void main() {
  runApp(const MaterialApp(home: MyApp()));
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final FFmpegService _service = FFmpegService();
  VideoMetadata? _metadata;
  ui.Image? _currentFrame;
  int _currentFrameIndex = 0;
  bool _isLoading = false;
  bool _isPlaying = false;
  Timer? _playTimer;

  @override
  void dispose() {
    _playTimer?.cancel();
    _service.release();
    super.dispose();
  }

  Future<void> _pickFile() async {
    FilePickerResult? result = await FilePicker.platform.pickFiles();

    if (result != null && result.files.single.path != null) {
      await _openMedia(result.files.single.path!);
    }
  }

  Future<void> _openMedia(String path) async {
    // Stop playing if playing
    _stopPlaying();

    setState(() {
      _metadata = null;
      _currentFrame = null;
      _currentFrameIndex = 0;
    });

    try {
      // Open video with FFmpegService (batch system is automatic)
      final metadata = await _service.openVideo(path);

      if (metadata == null) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Failed to open media file')),
          );
        }
        return;
      }

      setState(() {
        _metadata = metadata;
      });

      // Get first frame
      await _loadFrame(0);
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error opening media: $e')),
        );
      }
    }
  }

  Future<void> _loadFrame(int frameIndex) async {
    if (_metadata == null) return;

    // Clamp frame index
    frameIndex = frameIndex.clamp(0, _metadata!.totalFrames - 1);

    setState(() {
      _isLoading = true;
    });

    try {
      // Get frame using FFmpegService (uses batch manager automatically)
      final frameData = await _service.getFrameAtIndex(frameIndex);

      if (frameData != null && mounted) {
        // Convert to Flutter image
        final image = await FFmpegService.convertToFlutterImage(frameData);

        setState(() {
          _currentFrame = image;
          _currentFrameIndex = frameIndex;
          _isLoading = false;
        });
      } else {
        setState(() {
          _isLoading = false;
        });
      }
    } catch (e) {
      print('Error loading frame: $e');
      setState(() {
        _isLoading = false;
      });
    }
  }

  void _goToPreviousFrame() {
    if (_currentFrameIndex <= 0) return;
    _loadFrame(_currentFrameIndex - 1);
  }

  void _goToNextFrame() {
    if (_metadata == null || _currentFrameIndex >= _metadata!.totalFrames - 1) return;
    _loadFrame(_currentFrameIndex + 1);
  }

  void _togglePlayPause() {
    if (_isPlaying) {
      _stopPlaying();
    } else {
      _startPlaying();
    }
  }

  void _startPlaying() {
    if (_metadata == null || _isPlaying) return;

    setState(() {
      _isPlaying = true;
    });

    // Calculate frame duration based on FPS
    final frameDurationMs = (1000 / _metadata!.fps).round();

    _playTimer = Timer.periodic(Duration(milliseconds: frameDurationMs), (timer) {
      if (!_isPlaying || _metadata == null) {
        timer.cancel();
        return;
      }

      // Skip if already loading a frame
      if (_isLoading) {
        return;
      }

      // Go to next frame
      final nextFrame = _currentFrameIndex + 1;

      if (nextFrame >= _metadata!.totalFrames) {
        // End of video, stop playing
        _stopPlaying();
        return;
      }

      _loadFrame(nextFrame);
    });
  }

  void _stopPlaying() {
    if (!_isPlaying) return;

    setState(() {
      _isPlaying = false;
    });

    _playTimer?.cancel();
    _playTimer = null;
  }

  @override
  Widget build(BuildContext context) {
    // Get cache stats for display
    final cacheStats = _service.getCacheStats();

    return Scaffold(
      appBar: AppBar(
        title: const Text('FFmpeg Streamer Example'),
        backgroundColor: Colors.deepPurple,
      ),
      body: Column(
        children: [
          if (_metadata != null)
            Container(
              padding: const EdgeInsets.all(16.0),
              color: Colors.deepPurple.shade50,
              child: Column(
                children: [
                  Text(
                    '${_metadata!.width}x${_metadata!.height} @ ${_metadata!.fps.toStringAsFixed(2)} fps',
                    style: const TextStyle(
                        fontSize: 16, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    'Total frames: ${_metadata!.totalFrames} | Duration: ${(_metadata!.durationMs / 1000).toStringAsFixed(1)}s',
                    style: const TextStyle(fontSize: 14),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    'Current frame: $_currentFrameIndex',
                    style: TextStyle(
                      fontSize: 14,
                      color: _isPlaying ? Colors.green : Colors.blue,
                      fontWeight: _isPlaying ? FontWeight.bold : FontWeight.normal,
                    ),
                  ),
                  if (_isPlaying)
                    const Padding(
                      padding: EdgeInsets.only(top: 4.0),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(Icons.play_arrow, size: 16, color: Colors.green),
                          SizedBox(width: 4),
                          Text(
                            'PLAYING',
                            style: TextStyle(
                              fontSize: 12,
                              color: Colors.green,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ],
                      ),
                    ),
                  if (cacheStats != null)
                    Padding(
                      padding: const EdgeInsets.only(top: 8.0),
                      child: Text(
                        '📦 Cache: ${cacheStats.cachedBatches} batches, ${cacheStats.totalFramesInCache} frames (${cacheStats.memoryUsageMB.toStringAsFixed(1)} MB)',
                        style: const TextStyle(
                          fontSize: 11,
                          color: Colors.black54,
                        ),
                      ),
                    ),
                ],
              ),
            ),
          Expanded(
            child: Center(
              child: _isLoading && _currentFrame == null
                  ? const CircularProgressIndicator()
                  : _currentFrame != null
                      ? AspectRatio(
                          aspectRatio: _metadata!.width / _metadata!.height,
                          child: CustomPaint(
                            painter: VideoPainter(_currentFrame!),
                          ),
                        )
                      : const Text(
                          'No video loaded - Pick a file to start',
                          style: TextStyle(fontSize: 16),
                        ),
            ),
          ),

          // Slider for scrubbing
          if (_metadata != null && _currentFrame != null)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: Column(
                children: [
                  Slider(
                    value: _currentFrameIndex.toDouble(),
                    min: 0,
                    max: (_metadata!.totalFrames - 1).toDouble(),
                    divisions: _metadata!.totalFrames > 1 ? _metadata!.totalFrames - 1 : 1,
                    label: 'Frame $_currentFrameIndex',
                    onChanged: (value) {
                      if (_isPlaying) {
                        _stopPlaying();
                      }
                      _loadFrame(value.toInt());
                    },
                  ),
                  Text(
                    'Frame $_currentFrameIndex / ${_metadata!.totalFrames - 1}',
                    style: const TextStyle(fontSize: 12, color: Colors.grey),
                  ),
                ],
              ),
            ),

          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                // Frame index input
                if (_metadata != null)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 12.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: [
                        SizedBox(
                          width: 100,
                          child: TextField(
                            keyboardType: TextInputType.number,
                            decoration: InputDecoration(
                              labelText: 'Go to frame',
                              border: const OutlineInputBorder(),
                              hintText: '0-${_metadata!.totalFrames - 1}',
                              contentPadding: const EdgeInsets.all(8),
                            ),
                            onSubmitted: (value) {
                              final frameIndex = int.tryParse(value);
                              if (frameIndex != null) {
                                if (_isPlaying) {
                                  _stopPlaying();
                                }
                                _loadFrame(frameIndex);
                              }
                            },
                          ),
                        ),
                        ElevatedButton.icon(
                          onPressed: () {
                            if (_isPlaying) {
                              _stopPlaying();
                            }
                            final randomIndex =
                                (DateTime.now().millisecondsSinceEpoch %
                                        _metadata!.totalFrames)
                                    .toInt();
                            _loadFrame(randomIndex);
                          },
                          icon: const Icon(Icons.shuffle, size: 18),
                          label: const Text('Random'),
                        ),
                      ],
                    ),
                  ),
                // Play/Pause button
                if (_metadata != null)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8.0),
                    child: ElevatedButton.icon(
                      onPressed: _togglePlayPause,
                      icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
                      label: Text(_isPlaying ? 'Pause' : 'Play'),
                      style: ElevatedButton.styleFrom(
                        backgroundColor: _isPlaying ? Colors.orange : Colors.green,
                        foregroundColor: Colors.white,
                        padding: const EdgeInsets.symmetric(
                          horizontal: 32,
                          vertical: 14,
                        ),
                      ),
                    ),
                  ),
                // Navigation buttons
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    ElevatedButton.icon(
                      onPressed: _pickFile,
                      icon: const Icon(Icons.folder_open),
                      label: const Text('Pick File'),
                    ),
                    if (_metadata != null) ...[
                      ElevatedButton.icon(
                        onPressed: _isPlaying ? null : _goToPreviousFrame,
                        icon: const Icon(Icons.skip_previous),
                        label: const Text('Prev'),
                      ),
                      ElevatedButton.icon(
                        onPressed: _isPlaying ? null : _goToNextFrame,
                        icon: const Icon(Icons.skip_next),
                        label: const Text('Next'),
                      ),
                    ]
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class VideoPainter extends CustomPainter {
  final ui.Image image;

  VideoPainter(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImageRect(
      image,
      Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
      Rect.fromLTWH(0, 0, size.width, size.height),
      Paint()..filterQuality = FilterQuality.low,
    );
  }

  @override
  bool shouldRepaint(covariant VideoPainter oldDelegate) {
    return image != oldDelegate.image;
  }
}
3
likes
140
points
274
downloads

Publisher

unverified uploader

Weekly Downloads

A cross-platform Flutter FFI plugin that embeds FFmpeg to decode audio & video and stream raw frames — works on Android, iOS, macOS, Windows & Linux.

Homepage
Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

ffi, flutter, plugin_platform_interface

More

Packages that depend on ffmpeg_streamer

Packages that implement ffmpeg_streamer