Create custom gesture control for flutter video player using chewie
Fazail Alam Tue Jun 28 2022

Create custom gesture control for flutter video player using chewie

In this article, I will show you how to create custom gesture control for flutter video player using chewie package. First let see what gesture we are going to build:

Gestures

  1. Double tap
    1. double tap in the middle 20% of the screen to play/pause video.
    2. double tap in the left 40% of the screen to seek 10sec backward.
    3. double tap in the right 40% of the screen to seek 10sec forward.
  2. Vertical swipe
    1. vertical swipe from left 50% of the screen to change brightness of screen.
    2. vertical swipe from right 50% of the screen to change volume of video.
  3. Horizontal swipe to seek video forward/backward according to the swipe length.

Before we start

Please take a look at how to setup chewie first because this tutorial will proceed after setting up chewie player.

I hope you got the idea of what gesture we are going to be building. I assume that you have done setup for chewie package. Now let’s import the necessary dependencies.

dependencies:
---
screen_brightness: ^0.2.1
---
flutter:
    assets:
        - file.mp4 # short video file for testing is ./assets directory

Project Structure

/
├── assets/
│ └── file.mp4
├── lib/
│ ├── main.dart
│ ├── custom_controls.dart
│ └── video_page.dart
└── pubspec.yaml

Now pass the CustomControls widget to customControls named parameter of ChewieController.

_chewieController = ChewieController(
	...
	customControls: const CustomControls(),
);

We are done with video_page.dart, Now head over to custom_controls.dart file and create a CustomControls statefulwidget.

Adding variables

Now add variables which are going to be used in the widget. I have written the purpose of each variable in the comments.

Code: Variable used
  bool _fastForward = false; // To show/hide fast forward ui
  bool _fastRewind = false; // To show/hide fast rewind ui

  bool _volumeChange = false; // To show/hide volume ui
  bool _brightnessChange = false; // To show/hide brightness ui

  double _volume = 0.0; // current volume
  double _brightness = 1.0; // current brightness

  double _volumeDragStart = 0.0; // position from where volume drag started
  // position from where brightness drag started
  double _brightnessDragStart = 0.0;

  double _initialBrightness = 0.0; // initial brightness

  double _lastVolume = 0.0; // last volume after the volume changed
  double _lastBrightness = 0.0; // last volume after the brightness changed

  // last difference calculated from volume/brightness
  // to check if the last difference is equal to current
  // difference.
  double _lastDiff = 0;

  double _seekDragStart = 0.0; // horizontal drag starting position
  double _seekDuration = 10; // seek duration

  // package:screen_brightness
  final ScreenBrightness _screenBrightness = ScreenBrightness();
  // to control the video
  ChewieController? _chewieController;
  // a geetter to check with _chewieController on didChangeDependencies override method
  ChewieController get chewieController => _chewieController!;

Initializing and disposing

Now lets set initial brightness in _setInitialBrightness method where we assign _initialBrightness, _lastBrightness and _brightness to _screenBrightness.current inside try-catch block. This function will called initState override method. After that lets assign _chewieController, _volume and _lastVolume in didChangeDependencies method. Compare _chewieController with its getter and if it didn’t matches then call _dispose() method where we dispose _chewieController and reset screen brightness inside try catch-block. And also call _dispose() method in dispose() override method.

Code: Initialize - dispose
@override
void didChangeDependencies() {
    final oldController = _chewieController;
    _chewieController = ChewieController.of(context);
    _volume = _lastVolume = _chewieController!.videoPlayerController.value.volume;

    if (oldController != chewieController) {
      	_dispose();
    }
    super.didChangeDependencies();
}

_dispose() async {
    _chewieController!.dispose();
    try {
      	await _screenBrightness.resetScreenBrightness();
    } on PlatformException catch (e) {
      	debugPrint(e.message);
      	await _screenBrightness.setScreenBrightness(_initialBrightness);
    } catch (e) {
      	debugPrint(e.toString());
    }
}

@override
void dispose() {
	_dispose();
	super.dispose();
}

@override
void initState() {
    _setInitialBrightness();
    super.initState();
}

_setInitialBrightness() async {
    try {
      	_initialBrightness = _lastBrightness = _brightness = await _screenBrightness.current;
      	setState(() {});
    } catch (e) {
      	debugPrint(e.toString());
      	throw 'Failed to get system brightness';
    }
}

Build

In our build method we will get the screen size and then adding GestureDetector to the child of SizedBox. There are 8 callback functions which we are going to use in the tutorial from GestureDetector. All methods are extracted. If you want to build the controls from scratch then start with Container and it is necessary to pass color to container. If you want to use built-in Chewie controls then import AdaptiveControls from chewie package. The UI for gesture feedback are extracted into methods for reusability. I have written the purpose of each method in the comments.

Code: Build method
// chewie built in controls
import 'package:chewie/src/helpers/adaptive_controls.dart';
...
@override
  Widget build(BuildContext context) {
	// get screen size
    Size size = MediaQuery.of(context).size;
    return SizedBox(
      width: size.width,
      height: size.height,
      child: GestureDetector( // to detect gestures
        onDoubleTap: () {}, // this is needed in order to use `onDoubleTapDown`
        onDoubleTapDown: (details) { // get the position of double tap
          _handleDoubleTapDown(details, size);
        },
        onVerticalDragStart: (DragStartDetails details) { // get the start position from where vertical drag started
          _handleVerticalDragStart(details, size);
        },
        onVerticalDragUpdate: _handleVerticalDragUpdate, // get the position where drag is happening
        onVerticalDragEnd: _handleVerticalDragEnd, // do something after drag is done, finger/mouse-pointer lifted
        onHorizontalDragStart: _handleHorizontalDragStart, // get the start position from where horizontal drag started
        onHorizontalDragUpdate: _handleHorizontalDragUpdate, // get the position where drag is happening
        onHorizontalDragEnd: _handleHorizontalDragEnd, // do something after drag is done, finger/mouse-pointer lifted
        child: Stack(
          alignment: Alignment.center,
          children: [
            // if you want to make controls from scratch
            // Container(
            //   width: size.width,
            //   height: size.height,
            //   color: Colors.transparent, // this is important
            // ),
            // use default controls that comes with chewie player
			// if you use this import from `import 'package:chewie/src/helpers/adaptive_controls.dart';`
            const AdaptiveControls(),
            if (_fastRewind) _buildSeekUi(Icons.fast_rewind, size.width * 0.2), // fast rewind ui
            if (_fastForward) _buildSeekUi(Icons.fast_forward, size.width * 0.2), // fast forward ui
            if (_brightnessChange) _buildVolumeBrightnessUi(context, Icons.wb_sunny_outlined, size.width * 0.2), // brightness ui
            if (_volumeChange) _buildVolumeBrightnessUi(context, Icons.volume_up_outlined, size.width * 0.2), // volume ui
          ],
        ),
      ),
    );
  }

handleDoubleTapDown

  1. Play/Pause

In this method we will get the position of double tap and then we will check if the position is in the middle 20% of the screen. If it is then we will check if the video is playing. If it is then we will pause the video. If it is not then we will play the video.

  1. 10 seconds backward

If the tapped position is not in the middle 20% of the screen then we will check for next condition i.e. left 40% of the screen. If it is then we will subtract video’s current position with 10 seconds to seek backward.

  1. 10 seconds forward

And if this condition didn’t matches than we will check for next condition i.e. right 40% of the screen. If it is then we will add video’s current position with 10 seconds to seek forward. At last of this method we wait for 600 milliseconds to hide the ui.

Code: _handleDoubleTapDown
void _handleDoubleTapDown(TapDownDetails details, Size size) {
    // handle play/pause on double tap on the middle 20% of the screen
    if (details.globalPosition.dx >= size.width * 0.4 && details.globalPosition.dx <= size.width * 0.6) {
		// check if video is playing
		_chewieController!.isPlaying ?
	   		_chewieController!.videoPlayerController.pause() : _chewieController!.videoPlayerController.play();
    }
	else if (details.globalPosition.dx < size.width * 0.4) {
		// handle rewind on double tap on the left 40% of the screen
		setState(() {
			// make the fastForward UI visible
			_fastRewind = true;
		});
		// get current position of the video
		Duration currentPosition = _chewieController!.videoPlayerController.value.position;
		// seek 10 seconds back
		_chewieController!.seekTo(currentPosition - const Duration(seconds: 10));
    }
	else if (details.globalPosition.dx > size.width * 0.6) {
		// handle forward on double tap on the right 40% of the screen
		setState(() {
			_fastForward = true;
		});
		Duration currentPosition = _chewieController!.videoPlayerController.value.position;
		_chewieController!.seekTo(currentPosition + const Duration(seconds: 10));
    }
	// wait 600ms to hide seekUi
    Future.delayed(const Duration(milliseconds: 600), () {
      setState(() {
        _fastForward = false;
        _fastRewind = false;
      });
    });
}

handleVerticalDragStart

  1. Change brightness

User slide on left 50% of the screen to change brightness.

  1. change volume

User slide on right 50% of the screen to change volume.

Code: _handleVerticalDragStart
void _handleVerticalDragStart(DragStartDetails details, Size size) {
    // handle slide left 50% of the screen
    if (details.globalPosition.dx < size.width * 0.5) {
		// set the brightness ui visible and store the drag start position
		setState(() {
			_brightnessChange = true;
			_brightnessDragStart = details.globalPosition.dy;
		});
    }
	else if (details.globalPosition.dx >= size.width * 0.5) {
	// handle slide right 50% of the screen
		// set the volume ui visible and store the drag start position
		setState(() {
			_volumeChange = true;
			_volumeDragStart = details.globalPosition.dy;
		});
    }
}

handleVerticalDragUpdate

First check either brightness or volume was triggered. Then calculate the difference between current drag position with the property to change. If the finger/mouse-pointer is not dragging than simply return. Then calculate brightness/volume according to difference with last brightness/volume.

Code: _handleVerticalDragUpdate
void _handleVerticalDragUpdate(DragUpdateDetails details) {
    // check whether volume or brightness to update
    double toChange = _volumeChange ? _volumeDragStart : _brightnessDragStart;

    // get the difference from drag start position to current drag position
    double diff = details.globalPosition.dy - toChange;

    // don't update if there is no change in difference
    if (_lastDiff == diff) {
		return;
    }

    // Brightness
    if (_brightness >= 0.0 && _brightnessChange && diff.abs() < 100) {
		// don't update if brightness become 0 and difference is positive
		// to avoid increasing brightness when it is already 0
		if (_brightness == 0.0 && !diff.isNegative) {
			return;
		}
		setState(() {
			_lastDiff = diff;
			// get diff in decimal Ex: 500/ 100 = 0.5
			// then add diff to last brightness
			// Ex: -(0.5) + 0.3 = 0.2
			// change the result to positive
			// get the result upto one digit after decimal Ex: 0.56564654 = 0.5
			_brightness = _parseOneDecimal((-(diff * 0.01) + _lastBrightness).abs());
		});
    }
    // Volume
    if (_volume >= 0.0 && _volumeChange && diff.abs() < 100) {
		if (_volume == 0.0 && !diff.isNegative) {
			return;
		}
		setState(() {
			_lastDiff = diff;
			_volume = _parseOneDecimal((-(diff / 100) + _lastVolume).abs());
		});
    }
}

// convert: 0.5737845748 to 0.5
double _parseOneDecimal(double value) {
    return double.parse(value.toStringAsFixed(1));
}

handleVerticalDragEnd

First Set brightness/volume to 0.0 if it is greater or less than 0. Then set volume for volume change and brightness for brightness change. Note: brightness package only support android and ios platform. Lastly set current brightness/volume to last brightness/volume for updating from.

Code: _handleVerticalDragEnd
Future<void> _handleVerticalDragEnd(DragEndDetails details) async {
    if (_volume > 1.0) {
		_volume = 1.0;
    }
    if (_volume < 0.0) {
		_volume = 0.0;
    }
    if (_brightness > 1.0) {
		_brightness = 1.0;
    }
    if (_brightness < 0.0) {
		_brightness = 0.0;
    }
    if (_volumeChange) {
		await _chewieController!.setVolume(_volume);
    }
    if (_brightnessChange) {
		if (Platform.isAndroid || Platform.isIOS) {
			await _screenBrightness.setScreenBrightness(_brightness);
		}
    }
    setState(() {
		_lastVolume = _volume;
		_lastBrightness = _brightness;
		_brightnessChange = false;
		_volumeChange = false;
    });
}

handleHorizontalDragStart

In this method we only need the drag start position.

Code: _handleHorizontalDragStart
void _handleHorizontalDragStart(DragStartDetails details) {
    setState(() {
		_seekDragStart = details.globalPosition.dx;
    });
}

handleHorizontalDragUpdate

We need position difference between drag start and current drag position to determine the duration to seek.

Code: _handleHorizontalDragUpdate
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
    // get the difference from drag start position to current drag position
    double diff = _seekDragStart - details.globalPosition.dx;
    // user is sliding to left
    if (!diff.isNegative) {
		if (!_fastRewind) {
			setState(() {
			_fastRewind = true;
			_fastForward = false;
			});
		}
    } else {
		// user is sliding to right
		if (!_fastForward) {
			setState(() {
			_fastForward = true;
			_fastRewind = false;
			});
		}
    }
    setState(() {
		// Ex: 50/10 = 5
		_seekDuration = diff.abs() / 10;
    });
}

handleHorizontalDragEnd

First we get current position of the video. Then convert the difference between drag start and current drag position to duration. Then seek to the duration.

Code: _handleHorizontalDragEnd
Future<void> _handleHorizontalDragEnd(DragEndDetails details) async {
    Duration currentPosition = _chewieController!.videoPlayerController.value.position;
    Duration position = Duration(seconds: _seekDuration.toInt());

    if (_fastForward) {
		await _chewieController!.seekTo(currentPosition + position);
    } else if (_fastRewind) {
		await _chewieController!.seekTo(currentPosition - position);
    }
    setState(() {
		// reset to default value
		_seekDuration = 10;
		_fastForward = false;
		_fastRewind = false;
    });
}

Building seek UI

Code: Seek UI Widget
Positioned _buildSeekUi(IconData icon, double position) {
    return Positioned(
      left: _fastRewind ? position : null, // position left for fast rewind
      right: _fastForward ? position : null, // position right for fast forward
      child: AnimatedOpacity(
        duration: const Duration(milliseconds: 300),
        opacity: (_fastForward || _fastRewind) ? 1 : 0, // hide in idle state
        child: Container(
          width: 70,
          height: 40,
          padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
          decoration: BoxDecoration(
            color: Colors.grey[800]!.withOpacity(0.5),
            borderRadius: BorderRadius.circular(5),
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
			  // left arrow icon
              if (_fastRewind) ...[
                Icon(
                  icon,
                  size: 18,
                  color: Colors.white,
                ),
                const SizedBox(
                  width: 2,
                ),
              ],
			  // duration(seconds) to change
              Text(
                '${_seekDuration.round()}s',
                style: const TextStyle(color: Colors.white),
              ),
			  // right arrow icon
              if (_fastForward) ...[
                const SizedBox(
                  width: 2,
                ),
                Icon(
                  icon,
                  size: 18,
                  color: Colors.white,
                ),
              ],
            ],
          ),
        ),
      ),
    );
}

Building Brightness and Volume UI

Code: Building Brightness and Volume UI
Positioned _buildVolumeBrightnessUi(BuildContext context, IconData icon, double position) {
    return Positioned(
      left: _brightnessChange ? position : null, // position left for brightness change
      right: _volumeChange ? position : null, // position right for volume change
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
        height: 150,
        width: 24,
        alignment: Alignment.bottomCenter,
        decoration: BoxDecoration(
          color: Colors.grey[800],
          borderRadius: BorderRadius.circular(20),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            AnimatedContainer(
              duration: const Duration(milliseconds: 300),
              constraints: const BoxConstraints(maxHeight: 120), // max height for container
              height: (_brightnessChange ? _brightness : _volume) * 120, // Ex: 0.5 * 120 = 60
              width: 14,
              decoration: BoxDecoration(
                color: Theme.of(context).primaryColor,
                borderRadius: BorderRadius.circular(20),
              ),
            ),
            Icon(
              icon,
              color: Colors.white,
              size: 19,
            )
          ],
        ),
      ),
    );
}

Want frameworks news and updates?

Sign up for our newsletter to stay up to date.