Making a Video Collaboration Platform With Flutter Feed SDK

A video collaboration platform is a software application that enables users to share video and/or audio content and collaborate in real-time. This can be used for various purposes, such as online meetings, webinars, and distance learning.

Sacha A.
Sacha A.
Published August 2, 2022
Flutter Video Collaboration Platform

Some examples of video collaboration platforms are Frame.io, Wipster, or Vimeo. They are extremely useful for video editing teams collaborating on a video project.

There are many benefits to using a video collaboration platform, such as:

  • Increased collaboration and productivity
  • Improved communication and coordination
  • Reduced travel costs
  • Enhanced distance learning and training

However, there is no precise answer to this question as it depends on the specific features and requirements of the platform. Some key considerations for coding a video collaboration platform include:

  • A good media player and watching experience.
  • Backend and frontend logic for uploading video.
  • Allowing for real-time communication and interactions between users: one key feature is to be able to comment at a specific timestamp and jump to this timestamp.
  • Notifications.
  • Managing project status.

Stream provides many of these features out of the box, and Flutter has a good ecosystem around the video player, with various plugins, such as Chewie. This article will focus on the collaborative side of things using Streams Activity Feeds SDK.

Why Activity Feeds?

Activity Streams is a specification for syndicating social activities and activity-related information. It is designed to make it easy for users to follow the activities of friends and colleagues across the web.

There are many reasons why you might want to use Activity Streams. For example, you might want to:

  • Keep track of the latest activities from your friends and colleagues.
  • Create a news feed that includes activities from all your social networks.
  • Build a social network that allows users to share activities with each other.
  • Create a video collaboration platform that lets users share and discuss videos.

In this article, we will explore some of the core concepts of activity feeds by making a collaborative video platform and show you how easy it is to integrate with your application.

If you’re completely new to Activity Feeds, we recommend reading our Flutter Feeds Tutorial for a comprehensive getting-started guide. You can also take a look at the Feeds 101 documentation.

How To Make a Video Collaboration Platform Using the Stream Activity Feed SDK for Flutter

In the following sections we'll implement the Flutter code for creating video projects and displaying them.

We are also going to implement the most interesting feature in our project - leaving feedback to a video at a specific timeframe. In order to do so, we will:

  • model video projects as activities
  • create a feed_group of type "video_timeline"
  • use activity's extra_data such as the video_url, the project's description and theproject_name
  • model comments as reactions, in those comments we will store timestamp and the actual text

Creating & Displaying Video Projects

Let's inspect the code that will allow us to create a new video project.

We will need the following:

  • TextFields to fill out the project name and description.
  • Logic to upload the video file.
  • Logic to create a new activity (video project) and store all this information.

To accomplish the above we'll use the UploadListCore widget along with the image_picker package to choose the video from the user’s device. We only need to pick one video file. Then we’ll store all the information in the activity’s extraData field.

Uploading attachments is a common operation, and the Stream Feed Flutter Core package provides convenient controllers and UI to easily upload content.

We are going to need to override the mediaPreviewBuilder callback and implement a VideoPreviewCard widget because by default the core package doesn't handle video previews:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class NewProjectDialog extends StatelessWidget { const NewProjectDialog({Key? key}) : super(key: key); Widget build(BuildContext context) { final projectNameController = TextEditingController(); final projectDescController = TextEditingController(); final uploadController = FeedProvider.of(context).bloc.uploadController; return SimpleDialog(title: const Text('New project'), children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: projectNameController, decoration: const InputDecoration.collapsed( hintText: "Enter Project Name", )), ), Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: projectDescController, decoration: const InputDecoration.collapsed( hintText: "Enter Project Description", )), ), const UploadVideoPicker(), SizedBox( width: double.maxFinite, child: UploadListCore( uploadController: uploadController, loadingBuilder: (context) => const Center(child: CircularProgressIndicator()), uploadsErrorBuilder: (error) => Center(child: Text(error.toString())), uploadsBuilder: (context, uploads) { return uploads.isNotEmpty ? SizedBox( width: double.maxFinite, height: 200, child: FileUploadStateWidget( fileState: uploads.first, mediaPreviewBuilder: (file, mediaType) { if (mediaType == MediaType.video) { return VideoPreviewCard(file); } throw UnsupportedError('Unsupported media type'); }, onRemoveUpload: (attachment) { return uploadController.removeUpload(attachment); }, onCancelUpload: (attachment) { uploadController.cancelUpload(attachment); }, onRetryUpload: (attachment) async { return uploadController.uploadImage(attachment); }), ) : const SizedBox.shrink(); }, ), ), TextButton( child: const Text("Create"), onPressed: () async { final videoUrl = uploadController.getMediaUris()!.first.uri.toString(); await FeedProvider.of(context).bloc.onAddActivity( feedGroup: 'video_timeline', verb: "add", data: { "description": projectDescController.text, "project_name": projectNameController.text, "video_url": videoUrl, }, object: "video", time: DateTime.now()); Navigator.of(context).popUntil((route) => route.isFirst); }, ) ]); } }
New video project screen

The file picker is just an icon button, when clicked it takes the user video path and uploads it to the Stream CDN backend using the uploadMedia method. The url will then be available with the getMediaUris method from StreamFeed's Bloc.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class UploadVideoPicker extends StatelessWidget { const UploadVideoPicker({ Key? key, }) : super(key: key); Widget build(BuildContext context) { return Row( children: [ IconButton( icon: const Icon(Icons.file_copy), onPressed: () async { final ImagePicker _picker = ImagePicker(); final XFile? video = await _picker.pickVideo( source: ImageSource.gallery, ); if (video != null) { await FeedProvider.of(context) .bloc .uploadController .uploadMedia(AttachmentFile(path: video.path)); } else { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('Cancelled'))); } }, ), Text( 'Add a video', style: Theme.of(context).textTheme.caption, ), ], ); } }

Displaying the Video Projects

We should have a way to display a list of projects on the home screen. We are going to use the FlatFeedCore widget along with a GridView of ProjectPreviewCard.

The FlatFeedCore widget comes from the feeds package and provides easy-to-use builders to display a list of activities. By specifying the feedGroup you can determine which activities the widget should handle. For this application the feed group was set to video_timeline.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class ProjectPreviewBuilder extends StatelessWidget { const ProjectPreviewBuilder({Key? key}) : super(key: key); Widget build(BuildContext context) { return FlatFeedCore( feedGroup: 'video_timeline', userId: FeedProvider.of(context).bloc.currentUser!.id, loadingBuilder: (context) => const Center( child: CircularProgressIndicator(), ), emptyBuilder: (context) => const Center(child: Text('No video to review')), errorBuilder: (context, error) => Center( child: Text(error.toString()), ), limit: 10, flags: EnrichmentFlags().withReactionCounts().withOwnReactions(), feedBuilder: (context, activities) { return GridView.builder( itemCount: activities.length, itemBuilder: (context, index) => ProjectPreviewCard( reviewModel: ReviewProjectModel.fromActivity(activities[index]), ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, ), ); }, ); } }
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
Stream Feed project preview

The ReviewProjectModel is a convenient model where we do our casting operations on activity's extra data.

It makes the code a bit easier to read:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class ReviewProjectModel { final EnrichedActivity activity; final int reactionCounts; final String projectName; final String authorName; final DateTime publishedDate; final String description; final String videoUrl; ReviewProjectModel({ required this.activity, required this.reactionCounts, required this.projectName, required this.authorName, required this.publishedDate, required this.description, required this.videoUrl, }); factory ReviewProjectModel.fromActivity(EnrichedActivity activity) { final projectName = activity.extraData!["project_name"] as String; final reactionCounts = activity.reactionCounts?["comment"] ?? 0; final authorName = activity.actor!.data!["full_name"] as String; final publishedDate = activity.time!; final videoUrl = activity.extraData!['video_url'] as String; final description = activity.extraData!["description"] as String; return ReviewProjectModel( activity: activity, authorName: authorName, description: description, projectName: projectName, publishedDate: publishedDate, reactionCounts: reactionCounts, videoUrl: videoUrl); } }

The ProjectPreviewCard is just a Card widget that can be clicked. It displays a preview of the video and opens the ReviewProjectPage.

At the top of the ReviewProjectPage page we have the video player, the project's name, author, description and published date. The Chewie boilerplate code was taken from their example repo.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
class ReviewProjectPage extends StatefulWidget { const ReviewProjectPage({ Key? key, required this.reviewProjectModel, }) : super(key: key); final ReviewProjectModel reviewProjectModel; State<ReviewProjectPage> createState() => _ReviewProjectPageState(); } class _ReviewProjectPageState extends State<ReviewProjectPage> { late VideoPlayerController _videoPlayerController; ChewieController? _chewieController; void initState() { super.initState(); initializePlayer(); } void dispose() { _videoPlayerController.dispose(); _chewieController?.dispose(); super.dispose(); } Future<void> initializePlayer() async { _videoPlayerController = VideoPlayerController.network(widget.reviewProjectModel.videoUrl); await _videoPlayerController.initialize(); _createChewieController(); setState(() {}); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( widget.reviewProjectModel.projectName, ), ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ SizedBox( width: double.maxFinite, height: 100, child: _chewieController != null && _chewieController! .videoPlayerController.value.isInitialized ? //The video player Chewie( controller: _chewieController!, ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: const [ CircularProgressIndicator(), SizedBox(height: 20), Text('Loading'), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16), child: Text(widget.reviewProjectModel.projectName, style: const TextStyle(fontWeight: FontWeight.bold)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( children: [ Text(widget.reviewProjectModel.authorName, style: const TextStyle( color: StreamAppColors.darkGrey, fontWeight: FontWeight.bold)), Text( " uploaded ${formatPublishedDate(widget.reviewProjectModel.publishedDate)}", style: const TextStyle( color: StreamAppColors.darkGrey, fontWeight: FontWeight.bold)) ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Text( widget.reviewProjectModel.description, style: const TextStyle(color: StreamAppColors.darkGrey), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16), child: Text("${widget.reviewProjectModel.reactionCounts} Comments"), ), Expanded( child: CommentListViewBuilder( key: Key("${widget.reviewProjectModel.activity.id}_comments"), chewieController: _chewieController, lookupValue: widget.reviewProjectModel.activity.id!, ), ), // Spacer(), Padding( padding: const EdgeInsets.all(8.0), child: CommentSectionCard( userProfileImage: FeedProvider.of(context) .bloc .currentUser! .data?["profile_image"] as String? ?? "https://i.pravatar.cc/300", videoPlayerController: _videoPlayerController, onComment: (timestamp, text) async { await FeedProvider.of(context).bloc.onAddReaction( kind: "comment", activity: widget.reviewProjectModel.activity, feedGroup: 'video_timeline', data: { "timestamp": timestamp, "text": text, }, ); }, ), ), ], )); } void _createChewieController() { _chewieController = ChewieController( videoPlayerController: _videoPlayerController, autoPlay: true, looping: true, ); } }

Leaving Feedback on the Video

We are going to model feedback around reactions and child reactions on the activity we just created with the dialog.

The CommentSectionCard is at the bottom of the ReviewProjectPage. It's a textfield in a Card but when clicked it pauses the video. When the Send button is clicked we retrieve the current player position from the video controller along with the actual comment/feedback.

The VideoPositionIndicator is just a widget that displays the current video position in a performant way.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class CommentSectionCard extends StatelessWidget { const CommentSectionCard({ Key? key, required this.videoPlayerController, required this.userProfileImage, required this.onComment, }) : super(key: key); final VideoPlayerController videoPlayerController; final String userProfileImage; final Future<void> Function(int timestamp, String text) onComment; Widget build(BuildContext context) { final textController = TextEditingController(); return Card( child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Padding( padding: const EdgeInsets.all(8.0), child: FrameAvatar(url: userProfileImage), ), Flexible( child: Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: textController, onTap: () { videoPlayerController.pause(); }, decoration: const InputDecoration.collapsed( hintText: "Leave your comment here", )), ), ), ], ), Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ VideoPositionIndicator(videoPlayerController), TextButton( child: const Text("Send"), onPressed: () async { final timestamp = await videoPlayerController.position; await onComment(timestamp != null ? timestamp.inSeconds : 0, textController.text); textController.clear(); }, ) ], ), ) ], ), ); } }

In the CommentHeader we display the FrameAvatar, the username and the published date (formatted in a fuzzy matter, for example a day ago or a moment ago).

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CommentHeader extends StatelessWidget { const CommentHeader({Key? key, required this.commentModel}) : super(key: key); final FrameCommentModel commentModel; Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ FrameAvatar(url: commentModel.avatarUrl), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Text( commentModel.username, style: const TextStyle(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Text( formatPublishedDate(commentModel.date), ), ), ], ), ); } }

The frame comment model stores reaction extra data.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class FrameCommentModel { final int? timestamp; final DateTime date; final String text; final String username; final String avatarUrl; final int? numberOfLikes; final int? numberOfComments; final String lookupValue; final bool isLikedByUser; const FrameCommentModel({ this.timestamp, required this.date, required this.text, required this.username, required this.avatarUrl, this.numberOfLikes, this.numberOfComments, required this.lookupValue, required this.isLikedByUser, }); factory FrameCommentModel.fromReaction( Reaction reaction, String lookupValue) { final username = reaction.user!.data!['full_name'] as String; final avatarUrl = reaction.user!.data!['profile_image'] as String? ?? "https://i.pravatar.cc/300"; final timestamp = reaction.data!["timestamp"] as int?; final text = reaction.data!["text"] as String; final date = reaction.createdAt!; final numberOfComments = reaction.childrenCounts?['comment']; final isLikedByUser = (reaction.ownChildren?['like']?.length ?? 0) > 0; final numberOfLikes = reaction.childrenCounts?['like']; return FrameCommentModel( date: date, text: text, username: username, avatarUrl: avatarUrl, lookupValue: lookupValue, isLikedByUser: isLikedByUser, numberOfComments: numberOfComments, numberOfLikes: numberOfLikes, timestamp: timestamp, ); } }

In the content widget, we need an interactive text for the timestamp. When the timestamp is clicked, the player should advance the video to the desired timestamp.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class CommentContent extends StatelessWidget { const CommentContent({ Key? key, this.onSeekTo, required this.commentModel, }) : super(key: key); final Future<void> Function(int timestamp)? onSeekTo; final FrameCommentModel commentModel; Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ onSeekTo != null ? GestureDetector( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 12.0), child: Text( commentModel.timestamp != null ? convertDuration( Duration(seconds: commentModel.timestamp!)) : "", style: const TextStyle( color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 13, ), ), ), onTap: () { onSeekTo!(commentModel.timestamp!); }, ) : const SizedBox(width: 45), Text(commentModel.text), ], ); } }
Comment box

In the CommentFooter, we need a like button and a reply button. When clicked, the reply should display a TextField and a send button. On click of the Send icon, it should send the comment to Stream using bloc.onAddChildReaction().

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class CommentFooter extends StatefulWidget { const CommentFooter({ Key? key, required this.commentModel, required this.onToggleLikeReaction, required this.onReply, }) : super(key: key); final FrameCommentModel commentModel; /// The callback to reply to the comment final Future<void> Function(String reply) onReply; /// The callback to toggle the like reaction final Future<void> Function(bool isLikedByUser) onToggleLikeReaction; State<CommentFooter> createState() => _CommentFooterState(); } class _CommentFooterState extends State<CommentFooter> { bool showTextField = false; final replyController = TextEditingController(); Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ IconButton( icon: widget.commentModel.isLikedByUser ? const Icon(Icons.thumb_up, size: 14) : const Icon(Icons.thumb_up_outlined, size: 14), onPressed: () async { await widget .onToggleLikeReaction(widget.commentModel.isLikedByUser); }, ), if (widget.commentModel.numberOfLikes != null && widget.commentModel.numberOfLikes! > 0) Text( widget.commentModel.numberOfLikes!.toString(), style: const TextStyle(fontSize: 14), ), TextButton( child: const Text( "Reply", style: TextStyle(fontSize: 14), ), onPressed: () { setState(() { showTextField = !showTextField; }); }, ), if (showTextField) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: ReplyTextField(replyController: replyController), ), if (showTextField) IconButton( icon: const Icon( Icons.send, size: 12, ), onPressed: () async { await widget.onReply(replyController.text); replyController.clear(); }, ) ], ); } }

Finally, in the FameComment widget, along with the comment header and content we explained earlier, we need to display the number of comments. And when clicked it should drop down a list of comments. The operation should be reversible, and you should be able to collapse the list.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class FrameComment extends StatefulWidget { const FrameComment({ Key? key, required this.onSeekTo, required this.onReply, required this.onToggleLikeReaction, required this.buildReplies, required this.commentModel, }) : super(key: key); final FrameCommentModel commentModel; /// The callback to seek the video to the timestamp of the comment final Future<void> Function(int timestamp)? onSeekTo; /// The callback to reply to the comment final Future<void> Function(String reply) onReply; /// The callback to toggle the like reaction final Future<void> Function(bool isLikedByUser) onToggleLikeReaction; /// Build the replies to the comment final Widget Function(BuildContext) buildReplies; State<FrameComment> createState() => _FrameCommentState(); } class _FrameCommentState extends State<FrameComment> { bool displayReplies = false; Widget build(BuildContext context) { return Column( children: [ CommentHeader(commentModel: widget.commentModel), CommentContent( commentModel: widget.commentModel, onSeekTo: widget.onSeekTo, ), CommentFooter( commentModel: widget.commentModel, onReply: widget.onReply, onToggleLikeReaction: widget.onToggleLikeReaction, ), if (widget.commentModel.numberOfComments != null && widget.commentModel.numberOfComments! > 0) TextButton( child: Text( "${displayReplies ? 'Hide' : 'View'} ${widget.commentModel.numberOfComments!} replies", style: const TextStyle(color: StreamAppColors.blue), ), onPressed: () { setState(() { displayReplies = !displayReplies; }); }, ), if (displayReplies) widget.buildReplies(context), ], ); } }
Stream feed reactions and child reactions

The CommentListViewBuilder is just a thin wrapper around ReactionListCore combined with a Listview. The widget should be generic enough for displaying top-level comments and child comments i.e., "threads." The CommentListView is just a ListView of FrameComment, that we explained in the beginning.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class CommentListViewBuilder extends StatelessWidget { const CommentListViewBuilder({ Key? key, ChewieController? chewieController, required this.lookupValue, this.lookupAttr = LookupAttribute.activityId, }) : _chewieController = chewieController, super(key: key); final ChewieController? _chewieController; final LookupAttribute lookupAttr; final String lookupValue; Widget build(BuildContext context) { return ReactionListCore( lookupValue: lookupValue, lookupAttr: lookupAttr, kind: 'comment', flags: EnrichmentFlags() .withOwnChildren() .withOwnReactions() .withReactionCounts(), loadingBuilder: (context) => const Center( child: CircularProgressIndicator(), ), emptyBuilder: (context) => const Offstage(), errorBuilder: (context, error) => Center( child: Text(error.toString()), ), reactionsBuilder: (BuildContext context, List<Reaction> reactions) { return CommentListView( lookupValue: lookupValue, chewieController: _chewieController, reactions: reactions); }, ); } }

Finally, in CommentListView we build a list of frame comments. We define all the FrameComment callbacks including buildReplies where we return CommentListViewBuilder in a recursive manner.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class CommentListView extends StatelessWidget { const CommentListView({ Key? key, required this.lookupValue, required this.reactions, required ChewieController? chewieController, }) : _chewieController = chewieController, super(key: key); final String lookupValue; final List<Reaction> reactions; final ChewieController? _chewieController; Widget build(BuildContext context) { return ListView.separated( scrollDirection: Axis.vertical, separatorBuilder: (context, index) => const Divider(), shrinkWrap: true, reverse: true, itemCount: reactions.length, itemBuilder: (context, index) => FrameComment( commentModel: FrameCommentModel.fromReaction(reactions[index], lookupValue), buildReplies: (context) { return Row( children: [ const SizedBox( width: 40, ), Expanded( child: CommentListViewBuilder( lookupAttr: LookupAttribute.reactionId, lookupValue: reactions[index].id!, ), ), ], ); }, onToggleLikeReaction: (isLikedByUser) async { if (isLikedByUser) { FeedProvider.of(context).bloc.onRemoveChildReaction( kind: 'like', lookupValue: lookupValue, childReaction: reactions[index].ownChildren!['like']![0], parentReaction: reactions[index], ); } else { FeedProvider.of(context).bloc.onAddChildReaction( kind: 'like', lookupValue: lookupValue, reaction: reactions[index], ); } }, onSeekTo: _chewieController != null ? (int timestamp) async { await _chewieController ?.seekTo(Duration(seconds: timestamp)); } : null, onReply: (reply) async { await FeedProvider.of(context).bloc.onAddChildReaction( kind: "comment", reaction: reactions[index], lookupValue: lookupValue, data: {"text": reply}, ); }, )); } }

Conclusion

We built a nice proof of concept for our video collaboration platform, but where to go next?

You could build a feed of video projects and add in-app chat for private conversations. Use webhooks for sending notifications for when a comment is posted. You could also go the other route of an audio platform and build an audio player like SoundCloud to comment on specific timestamps.

You can find the full source code of this project here.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->