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 thevideo_url
, the project'sdescription
and theproject_name
- model comments as reactions, in those comments we will store
timestamp
and the actualtext
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:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182class 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); }, ) ]); } }
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.
123456789101112131415161718192021222324252627282930313233343536class 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.
1234567891011121314151617181920212223242526272829303132class 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, ), ); }, ); } }
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:
123456789101112131415161718192021222324252627282930313233343536class 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145class 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263class 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).
123456789101112131415161718192021222324252627282930class 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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546class 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.
1234567891011121314151617181920212223242526272829303132333435363738394041class 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), ], ); } }
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()
.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273class 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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162class 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), ], ); } }
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.
1234567891011121314151617181920212223242526272829303132333435363738class 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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273class 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.