Search code examples
flutterdartsnapchatinstagram-storyflutter-video-player

How to play videos sequentialy on video_player without delay?


I'm looking to recreate Snapchat's back-to-back video format in Flutter. Since video_player is lacking callbacks for when the video finishes (and is otherwise prone to callback hell), I was wondering if anyone has some pointers for building something like this.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';


void main() {
  runApp(MaterialApp(
    title: 'My app', // used by the OS task switcher
    home: MyHomePage(),
  ));
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  List<VideoPlayerController> _controllers = [];
  VoidCallback listener;
  bool _isPlaying = false;
  int _current = 0;

  @override
  void initState() {

   super.initState();


    // Add some sample videos
    _controllers.add(VideoPlayerController.network(
      'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    ));
    _controllers.add(VideoPlayerController.network(
      'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    ));
    _controllers.add(VideoPlayerController.network(
      'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    ));

    this.tick();

    // Try refreshing by brute force (this isn't going too well)
    new Timer.periodic(Duration(milliseconds: 100), (Timer t) {
      int delta = 99999999;
      if(_controllers[_current].value != null) {
        delta = (_controllers[_current].value.duration.inMilliseconds - _controllers[_current].value.position.inMilliseconds);
      }
      print("Tick " + delta.toString());
      if(delta < 500) {
        _current += 1;
        this.tick();
      }
    });

  }

  void tick() async {
    print("Current: " + _current.toString());

    await _controllers[_current].initialize();
    await _controllers[_current].play();

    print("Ready");



    setState((){
      _current = _current;
    });

  }

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: _controllers[_current].value.aspectRatio,
      // Use the VideoPlayer widget to display the video
      child: VideoPlayer(_controllers[_current]),
    );
  }
}

What I have now plays the first video, but there is a very long delay between the first and second. I believe it has to do with my inability to get rid of the listener attached to the 0th item.


Solution

  • Update

    There is a preCache() method in the better_player library, which uses the underlying native players' cache implementations. It would be an ideal solution for seamless sequential video playback. The old answer below is a "hacky" way of achieving this.

    Unfortunately, the video_player library still has no pre-caching (or even caching) feature.

    Old answer

    Initializing a VideoPlayerController.network() may take some time to finish. You can initialize the controller of the next video while playing the current one. This will take more memory, but I don't think it will create huge problems if you prebuffer only one or two videos. Then, when the next or previous buttons are pressed, the video will be ready to play.

    Here is my workaround. It prebuffers the previous and next videos, skips to the next video when finished, shows the current position and buffer, and pauses and plays on a long press.

    import 'package:flutter/material.dart';
    import 'package:video_player/video_player.dart';
    
    main() {
      runApp(const MaterialApp(
        home: VideoPlayerDemo(),
      ));
    }
    
    class VideoPlayerDemo extends StatefulWidget {
      const VideoPlayerDemo({super.key});
    
      @override
      State<VideoPlayerDemo> createState() => _VideoPlayerDemoState();
    }
    
    class _VideoPlayerDemoState extends State<VideoPlayerDemo> {
      int index = 0;
      double _position = 0;
      double _buffer = 0;
      bool _lock = true;
      final Map<String, VideoPlayerController> _controllers = {};
      final Map<int, VoidCallback> _listeners = {};
      static const _urls = {
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#1',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#2',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#3',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#4',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#5',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#6',
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#7',
      };
    
      @override
      void initState() {
        super.initState();
    
        if (_urls.isNotEmpty) {
          _initController(0).then((_) {
            _playController(0);
          });
        }
    
        if (_urls.length > 1) {
          _initController(1).whenComplete(() => _lock = false);
        }
      }
    
      VoidCallback _listenerSpawner(index) {
        return () {
          int dur = _controller(index).value.duration.inMilliseconds;
          int pos = _controller(index).value.position.inMilliseconds;
          int buf = _controller(index).value.buffered.last.end.inMilliseconds;
    
          setState(() {
            if (dur <= pos) {
              _position = 0;
              return;
            }
            _position = pos / dur;
            _buffer = buf / dur;
          });
          if (dur - pos < 1) {
            if (index < _urls.length - 1) {
              _nextVideo();
            }
          }
        };
      }
    
      VideoPlayerController _controller(int index) {
        return _controllers[_urls.elementAt(index)]!;
      }
    
      Future<void> _initController(int index) async {
        final url = Uri.parse(_urls.elementAt(index));
        var controller = VideoPlayerController.networkUrl(url);
        _controllers[_urls.elementAt(index)] = controller;
        await controller.initialize();
      }
    
      void _removeController(int index) {
        _controller(index).dispose();
        _controllers.remove(_urls.elementAt(index));
        _listeners.remove(index);
      }
    
      void _stopController(int index) {
        _controller(index).removeListener(_listeners[index]!);
        _controller(index).pause();
        _controller(index).seekTo(const Duration(milliseconds: 0));
      }
    
      void _playController(int index) async {
        if (!_listeners.keys.contains(index)) {
          _listeners[index] = _listenerSpawner(index);
        }
        _controller(index).addListener(_listeners[index]!);
        await _controller(index).play();
        setState(() {});
      }
    
      void _previousVideo() {
        if (_lock || index == 0) {
          return;
        }
        _lock = true;
    
        _stopController(index);
    
        if (index + 1 < _urls.length) {
          _removeController(index + 1);
        }
    
        _playController(--index);
    
        if (index == 0) {
          _lock = false;
        } else {
          _initController(index - 1).whenComplete(() => _lock = false);
        }
      }
    
      void _nextVideo() async {
        if (_lock || index == _urls.length - 1) {
          return;
        }
        _lock = true;
    
        _stopController(index);
    
        if (index - 1 >= 0) {
          _removeController(index - 1);
        }
    
        _playController(++index);
    
        if (index == _urls.length - 1) {
          _lock = false;
        } else {
          _initController(index + 1).whenComplete(() => _lock = false);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("Playing ${index + 1} of ${_urls.length}"),
          ),
          body: Stack(
            children: <Widget>[
              GestureDetector(
                onLongPressStart: (_) => _controller(index).pause(),
                onLongPressEnd: (_) => _controller(index).play(),
                child: Center(
                  child: AspectRatio(
                    aspectRatio: _controller(index).value.aspectRatio,
                    child: Center(child: VideoPlayer(_controller(index))),
                  ),
                ),
              ),
              Positioned(
                child: Container(
                  height: 10,
                  width: MediaQuery.of(context).size.width * _buffer,
                  color: Colors.grey,
                ),
              ),
              Positioned(
                child: Container(
                  height: 10,
                  width: MediaQuery.of(context).size.width * _position,
                  color: Colors.greenAccent,
                ),
              ),
            ],
          ),
          floatingActionButton: Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: <Widget>[
              FloatingActionButton(
                onPressed: _previousVideo,
                child: const Icon(Icons.arrow_back),
              ),
              const SizedBox(width: 24),
              FloatingActionButton(
                onPressed: _nextVideo,
                child: const Icon(Icons.arrow_forward),
              ),
            ],
          ),
        );
      }
    }
    

    All of the logic lives inside the state object, therefore makes it dirty. I might turn this into a package in the future.