diff --git a/example/assets/sample_data.json b/example/assets/sample_data.json index a0b44f50..99991e5b 100644 --- a/example/assets/sample_data.json +++ b/example/assets/sample_data.json @@ -17,6 +17,11 @@ "style":"display: block; margin: auto;" } }, + { + "insert": { + "video": "https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4" + } + }, { "insert":"\nRich text editor for Flutter" }, diff --git a/lib/src/models/documents/document.dart b/lib/src/models/documents/document.dart index 527d220b..b527551a 100644 --- a/lib/src/models/documents/document.dart +++ b/lib/src/models/documents/document.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:tuple/tuple.dart'; @@ -209,16 +210,19 @@ class Document { static void _autoAppendNewlineAfterImage( int i, List ops, Operation op, Delta res) { - final nextOpIsImage = - i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String; + final nextOpIsImage = i + 1 < ops.length && + ops[i + 1].isInsert && + ops[i + 1].data is Map && + (ops[i + 1].data as Map).containsKey('image'); if (nextOpIsImage && op.data is String && (op.data as String).isNotEmpty && !(op.data as String).endsWith('\n')) { res.push(Operation.insert('\n')); } - // Currently embed is equivalent to image and hence `is! String` - final opInsertImage = op.isInsert && op.data is! String; + // embed could be image or video + final opInsertImage = + op.isInsert && op.data is Map && (op.data as Map).containsKey('image'); final nextOpIsLineBreak = i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is String && diff --git a/lib/src/models/documents/nodes/embed.dart b/lib/src/models/documents/nodes/embed.dart index 07e6a6b3..18c550fe 100644 --- a/lib/src/models/documents/nodes/embed.dart +++ b/lib/src/models/documents/nodes/embed.dart @@ -39,4 +39,7 @@ class BlockEmbed extends Embeddable { static const String imageType = 'image'; static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, imageUrl); + + static const String videoType = 'video'; + static BlockEmbed video(String videoUrl) => BlockEmbed(videoType, videoUrl); } diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 59a031a0..68370e91 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -25,6 +25,7 @@ import 'delegate.dart'; import 'image.dart'; import 'raw_editor.dart'; import 'text_selection.dart'; +import 'video_app.dart'; const linkPrefixes = [ 'mailto:', // email @@ -105,6 +106,9 @@ Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { : isBase64(imageUrl) ? Image.memory(base64.decode(imageUrl)) : Image.file(io.File(imageUrl)); + case 'video': + final videoUrl = node.value.data; + return VideoApp(videoUrl: videoUrl); default: throw UnimplementedError( 'Embeddable type "${node.value.type}" is not supported by default ' diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart index 559e5e1a..8129bc1a 100644 --- a/lib/src/widgets/simple_viewer.dart +++ b/lib/src/widgets/simple_viewer.dart @@ -20,6 +20,7 @@ import 'delegate.dart'; import 'editor.dart'; import 'text_block.dart'; import 'text_line.dart'; +import 'video_app.dart'; class QuillSimpleViewer extends StatefulWidget { const QuillSimpleViewer({ @@ -107,6 +108,9 @@ class _QuillSimpleViewerState extends State : isBase64(imageUrl) ? Image.memory(base64.decode(imageUrl)) : Image.file(io.File(imageUrl)); + case 'video': + final videoUrl = node.value.data; + return VideoApp(videoUrl: videoUrl); default: throw UnimplementedError( 'Embeddable type "${node.value.type}" is not supported by default ' diff --git a/lib/src/widgets/video_app.dart b/lib/src/widgets/video_app.dart new file mode 100644 index 00000000..f66aaa26 --- /dev/null +++ b/lib/src/widgets/video_app.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// Widget for playing back video +/// Refer to https://github.com/flutter/plugins/tree/master/packages/video_player/video_player +class VideoApp extends StatefulWidget { + const VideoApp({required this.videoUrl}); + final String videoUrl; + + @override + _VideoAppState createState() => _VideoAppState(); +} + +class _VideoAppState extends State { + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + + _controller = widget.videoUrl.startsWith('http') + ? VideoPlayerController.network(widget.videoUrl) + : VideoPlayerController.asset(widget.videoUrl) + ..initialize().then((_) { + // Ensure the first frame is shown after the video is initialized, + // even before the play button has been pressed. + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 300, + child: InkWell( + onTap: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, + child: Stack(alignment: Alignment.center, children: [ + Center( + child: _controller.value.isInitialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ) + : const CircularProgressIndicator()), + _controller.value.isPlaying || !_controller.value.isInitialized + ? const SizedBox.shrink() + : const Icon( + Icons.play_arrow, + size: 60, + color: Colors.white, + ) + ]), + ), + ); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 039a3e6c..36abb395 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: tuple: ^2.0.0 url_launcher: ^6.0.2 pedantic: ^1.11.0 + video_player: ^2.1.10 dev_dependencies: flutter_test: