Build a Youtube Clone with Strapi and Flutter: Part 3
Introduction
Welcomet to the Part 3 of our blog series. I would advise you to read Part 1 and Part 2 to understand how we got to this point.
For reference purposes, here’s the outline of this blog series:
- Part 1: Building a Video Streaming Backend with Strapi
- Part 2: Creating Services and State Management
- Part 3: Building the App UI with Flutter
In Part 3, we’ll learn how to build the frontend with Flutter and consume the APIs to implement a functional YouTube clone application. Before we move futher, let’s look at the folder structure for the Flutter app we’ll be building:
📦youtube_clone
┣ 📂tests: Test files to check app functionality.
┣ 📂assets: General assets like images or fonts.
┣ 📂ios: iOS-specific files.
┣ 📂android: Android-specific files.
┣ 📂assets
┃ ┗ 📂images: Stores image assets.
📂lib
┃ ┣ 📂providers: Handles state for WebSocket, user, and video data.
┃ ┃ ┣ 📜socket_provider.dart: Manages WebSocket data.
┃ ┃ ┣ 📜user_provider.dart: Manages user state.
┃ ┃ ┗ 📜video_provider.dart: Manages video state.
┃ ┣ 📂services: Deals with API calls for users and videos.
┃ ┃ ┣ 📜user_service.dart: User API logic.
┃ ┃ ┗ 📜video_service.dart: Video API logic.
┃ ┣ 📂utils: Useful functions.
┃ ┃ ┗ 📜getter.dart: Fetching data helpers.
┃ ┗ 📜main.dart: App's starting point.
┣ 📂web: Web-specific files.
┣ 📂windows: Windows-specific files.
┣ 📜analysis_options.yaml: Code analysis settings.
┣ 📜package-lock.json: Locks npm dependencies.
┣ 📜pubspec.lock: Locks Dart package versions.
┗ 📜pubspec.yaml: Project settings and dependencies.
Creating User Interface with Flutter
Now that you set up your Flutter project, configured the permissions, and assets, and installed the required project dependencies, let’s proceed to building out the user interface.
Creating the Home Screen
Create a new directory named screens in your lib
directory. In the screens directory, create a home_screen.dart
file and add the code snippet below:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:youtube_clone/providers/user_provider.dart';
import 'package:youtube_clone/providers/video_provider.dart';
import 'package:youtube_clone/utils/getter.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<VideoProvider>(context, listen: false).fetchVideos();
});
}
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Image.asset(
'assets/images/YouTube_logo.png',
width: 90,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
showSearch(
context: context,
delegate: VideoSearchDelegate(),
);
},
),
GestureDetector(
onTap: () {},
child: userProvider.token != null
? CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
),
radius: 20,
)
: ElevatedButton(
onPressed: () {},
child: const Text('Sign in'),
),
),
],
),
),
body: Column(
children: [
Expanded(
child: Consumer<VideoProvider>(
builder: (context, videoProvider, child) {
if (videoProvider.videos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: videoProvider.videos.length,
itemBuilder: (context, index) {
final video = videoProvider.videos[index];
return _buildVideoTitle(video);
},
);
},
),
),
],
),
);
}
Widget _buildVideoTitle(Map<String, dynamic> video) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Image.network(
'${getBaseUrl()}${video['thumbnail']['url']}',
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
],
),
ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
leading: CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${video['uploader']['profile_picture']['url']}',
),
radius: 20,
),
title: Text(
video['title'],
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${video['uploader']['username']} • ${video['views']?.length.toString()} views • ${_formatDaysAgo(video['publishedAt'])}',
),
trailing: IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
},
),
),
],
);
}
String _formatDaysAgo(String publishedAt) {
final publishedDate = DateTime.parse(publishedAt);
final now = DateTime.now();
final difference = now.difference(publishedDate).inDays;
if (difference == 0) {
return 'Today';
} else if (difference == 1) {
return 'Yesterday';
} else {
return '$difference days ago';
}
}
}
class VideoSearchDelegate extends SearchDelegate<String> {
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
close(context, '');
},
);
}
@override
Widget buildResults(BuildContext context) {
final videoProvider = Provider.of<VideoProvider>(context, listen: false);
final results = videoProvider.videos
.where((video) =>
video['title'].toLowerCase().contains(query.toLowerCase()))
.toList();
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final video = results[index];
return ListTile(
title: Text(video['title']),
onTap: () async {
await Provider.of<VideoProvider>(context, listen: false)
.increaseViews(video['documentId']);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
VideoPlayerScreen(videoId: video['documentId']),
),
);
},
);
},
);
}
@override
Widget buildSuggestions(BuildContext context) {
final videoProvider = Provider.of<VideoProvider>(context, listen: false);
final suggestions = videoProvider.videos
.where((video) =>
video['title'].toLowerCase().contains(query.toLowerCase()))
.toList();
return ListView.builder(
itemCount: suggestions.length,
itemBuilder: (context, index) {
final suggestion = suggestions[index];
return ListTitle(
title: Text(suggestion['title']),
onTap: () {
query = suggestion['title'];
showResults(context);
},
);
},
);
}
}
In the HomeScreen
widget, the initState
method triggers a fetch for video data using the VideoProvider
as soon as the screen is loaded. The app bar features a logo, a search button to show video search results, and a profile avatar or sign-in button depending on the user's authentication state. The body of the screen displays a list of videos fetched from the backend, with each video showing a thumbnail, uploader information, and other details. The VideoSearchDelegate
enables searching and filtering of videos by title, showing matching results and suggestions as the user types.
Now update your main.dart
file to render the HomeScreen
widget:
//...
import 'package:youtube_clone/screens/home_screen.dart';
//...
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
//...
return MaterialApp(
//...
home: const HomeScreen(),
);
}
}
Creating the Video Player Screen
Create a new video_player_screen.dart
file in the lib/screens
folder and add the code below for the Video player screen.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import 'package:youtube_clone/providers/user_provider.dart';
import 'package:youtube_clone/providers/video_provider.dart';
import 'package:youtube_clone/utils/getter.dart';
class VideoPlayerScreen extends StatefulWidget {
final String videoId;
const VideoPlayerScreen({super.key, required this.videoId});
@override
_VideoPlayerScreenState createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
late VideoPlayerController _controller;
final TextEditingController _commentController = TextEditingController();
@override
void initState() {
super.initState();
_initializeVideo();
}
@override
void didUpdateWidget(covariant VideoPlayerScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.videoId != oldWidget.videoId) {
_initializeVideo();
}
}
void _initializeVideo() {
final videoProvider = Provider.of<VideoProvider>(context, listen: false);
final video = videoProvider.videos
.firstWhere((v) => v['documentId'] == widget.videoId, orElse: () => {});
print('${getBaseUrl()}${video['video_file']['url']}');
if (video.isNotEmpty) {
_controller = VideoPlayerController.network(
'${getBaseUrl()}${video['video_file']['url']}')
..initialize().then((_) {
if (mounted) {
setState(() {});
}
});
}
}
@override
void dispose() {
_controller.dispose();
_commentController.dispose();
super.dispose();
}
void _submitComment(BuildContext context, String videoId) {
if (_commentController.text.isNotEmpty) {
final videoProvider = Provider.of<VideoProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false);
videoProvider.commentOnVideo(
videoId, _commentController.text, userProvider.user?['documentId']);
_commentController.clear();
setState(() {});
}
}
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context, listen: false);
return Consumer<VideoProvider>(
builder: (context, videoProvider, child) {
final video = videoProvider.videos.firstWhere(
(v) => v['documentId'] == widget.videoId,
orElse: () => {});
if (video.isEmpty) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pop(context);
},
),
),
body: Center(child: Text('Video not found')),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pop(context);
},
),
),
body: Stack(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_controller.value.isInitialized)
AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
_controller.value.isPlaying
? Icons.pause
: Icons.play_arrow,
),
onPressed: () {
setState(() {
_controller.value.isPlaying
? _controller.pause()
: _controller.play();
});
},
),
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
FullscreenVideoPlayer(
controller: _controller),
),
);
},
),
],
),
const SizedBox(height: 8),
Text(
video['title'] ?? 'No Title',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
video['description'] ?? 'No Description',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
Row(children: [
CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${video['uploader']['profile_picture']['url']}'),
),
const SizedBox(width: 8),
Text(
(video['uploader']['username']).toString(),
),
const SizedBox(width: 10),
Text(
(video['uploader']['subscribers']?.length ?? 0)
.toString(),
),
const SizedBox(width: 10),
Row(
children: [
TextButton(
onPressed: () async {
await videoProvider
.likeVideo(video['documentId']);
},
child: const Icon(Icons.thumb_up),
),
Text(video['likes'].length.toString()),
],
),
const SizedBox(width: 15),
// Check if the user is logged in
if (userProvider.user != null)
ElevatedButton(
onPressed: () async {
await videoProvider.subscribeToChannel(
video['uploader']['id']);
},
child: Text(
video['uploader']['subscribers'] != null &&
video['uploader']['subscribers']!.any(
(subscriber) =>
subscriber['id'] ==
userProvider.user!['id'])
? "Unsubscribe"
: "Subscribe",
),
),
]),
],
),
),
Row(
children: [
const Text("Comments"),
const SizedBox(width: 8),
Text(video['comments'].length.toString()),
],
),
Container(
padding: const EdgeInsets.all(16.0),
color: Colors.black12,
child: Column(
children: video['comments'].map<Widget>((comment) {
return ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${comment['user']['profile_picture']['url']}'),
),
title: Text(comment['user']['username']),
subtitle: Text(comment['text']),
);
}).toList(),
),
),
const SizedBox(height: 70),
],
),
),
if (userProvider.token != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
padding: const EdgeInsets.all(15),
child: Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Add a comment...',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () =>
_submitComment(context, video['documentId']),
child: const Text('Post'),
),
],
),
),
),
],
),
);
},
);
}
}
class FullscreenVideoPlayer extends StatelessWidget {
final VideoPlayerController controller;
const FullscreenVideoPlayer({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
),
);
}
}
In the VideoPlayerScreen
widget, the initState
method initializes the VideoPlayerController
to load and play the video when the screen is first built. The didUpdateWidget
method ensures that the video is reloaded if the videoId
changes. The build
method displays the video player, controls for playback and fullscreen mode, video details, uploader information, and user interaction options like liking and subscribing. It also provides a comment section where authenticated users can post comments. The FullscreenVideoPlayer
widget allows the video to be viewed in fullscreen mode.
To allow you to navigate to this screen when you click on any video from the HomeScreen
widget, update the _buildVideoTile
widget in the lib/screens/home_screen.dart
file to add navigation to the VideoPlayerScreen
widget.
import 'package:youtube_clone/screens/video_player_screen.dart';
//...
Widget _buildVideoTitle(Map<String, dynamic> video) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () async {
await Provider.of<VideoProvider>(context, listen: false)
.increaseViews(video['documentId']);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
VideoPlayerScreen(videoId: video['documentId']),
),
);
},
child: Stack(
children: [
Image.network(
'${getBaseUrl()}${video['thumbnail']['url']}',
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
],
),
),
ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
leading: CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${video['uploader']['profile_picture']['url']}',
),
radius: 20,
),
title: GestureDetector(
onTap: () async {
await Provider.of<VideoProvider>(context, listen: false)
.increaseViews(video['documentId']);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
VideoPlayerScreen(videoId: video['documentId']),
),
);
},
child: Text(
video['title'],
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Text(
'${video['uploader']['username']} • ${video['views']?.length.toString()} views • ${_formatDaysAgo(video['publishedAt'])}',
),
trailing: IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {},
),
onTap: () async {
await Provider.of<VideoProvider>(context, listen: false)
.increaseViews(video['documentId']);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
VideoPlayerScreen(videoId: video['documentId']),
),
);
},
),
],
);
}
//...
In the above code, we added an onTab
, which is triggered when the user clicks on a video. It calls the VideoProvider
class increaseViews
method which will increase the number of views for this video. Now, click on the video to view the VideoPlayerScreen
widget.
Creating the Auth Screen
To allow users to authenticate into the application, including signing in and signing up, we’ll create a new file named auth_screen.dart
in the lib/screens
directory for user sign-in and sign-up functionalities:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:youtube_clone/providers/user_provider.dart';
import 'package:youtube_clone/screens/home_screen.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
_AuthScreenState createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
XFile? _profilePicture;
bool _isLogin = true;
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context);
final ImagePicker _picker = ImagePicker();
return Scaffold(
appBar: AppBar(
title: Text(_isLogin ? 'Login' : 'Sign Up'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
children: [
if (!_isLogin)
TextField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
),
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
),
TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 20),
if (!_isLogin)
_profilePicture == null
? TextButton(
onPressed: () async {
final pickedFile = await _picker.pickImage(
source: ImageSource.gallery);
setState(() {
_profilePicture = pickedFile;
});
},
child: const Text('Select Profile Picture'),
)
: Column(
children: [
Image.file(
File(_profilePicture!.path),
height: 100,
width: 100,
),
TextButton(
onPressed: () async {
final pickedFile = await _picker.pickImage(
source: ImageSource.gallery);
setState(() {
_profilePicture = pickedFile;
});
},
child: const Text('Change Profile Picture'),
),
],
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
if (_isLogin) {
await userProvider.login(
_emailController.text,
_passwordController.text,
);
} else {
await userProvider.signup(
File(_profilePicture!.path),
_emailController.text,
_usernameController.text,
_passwordController.text,
);
}
if (userProvider.token != null) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => const HomeScreen()),
(Route<dynamic> route) => false,
);
}
},
child: Text(_isLogin ? 'Login' : 'Sign Up'),
),
const SizedBox(height: 20),
TextButton(
onPressed: () {
setState(() {
_isLogin = !_isLogin;
});
},
child: Text(_isLogin
? 'Don\'t have an account? Sign Up'
: 'Already have an account? Login'),
),
const SizedBox(height: 20),
Text(userProvider.message ?? "")
],
),
),
),
);
}
}
In the AuthScreen
class, we added functionality to toggle between login and sign-up forms using a boolean
flag. For the sign-up process, we integrated an ImagePicker
to allow users to select or change their profile picture, which is displayed in the UI. The authentication button now handles both login and sign-up actions, and upon successful authentication, navigates to the HomeScreen
while clearing the navigation stack to prevent returning to the AuthScreen
. Lastly, we included user feedback messages and utilized setState
to update the UI based on user interactions and form inputs.
To access this screen, you need to update the _build
widget in the lib/screens/home_screen.dart
file to add navigation to the AuthScreen
widget:
//...
import 'package:youtube_clone/screens/auth_screen.dart';
//...
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Image.asset(
'assets/images/YouTube_logo.png',
width: 90,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
showSearch(
context: context,
delegate: VideoSearchDelegate(),
);
},
),
GestureDetector(
child: userProvider.token != null
? CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
),
radius: 20,
)
: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
);
},
child: const Text('Sign in'),
),
),
],
),
),
body: Column(
children: [
// _buildFilterBar(),
Expanded(
child: Consumer<VideoProvider>(
builder: (context, videoProvider, child) {
if (videoProvider.videos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: videoProvider.videos.length,
itemBuilder: (context, index) {
final video = videoProvider.videos[index];
return _buildVideoTile(video);
},
);
},
),
),
],
),
);
}
//...
Now click on the Sign in button from the HomeScreen
to navigate to AuthScreen
. Create a new account or log in with your Strapi admin credentials.
Creating the Profile Page
After successfully signin or signup to the application, a user should be able to access their profile, so they can add new videos to their channel and see their channel information. To handle that, create a new profile_screen.dart
file in the lib/screens
directory and add the code below:
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import 'package:youtube_clone/providers/user_provider.dart';
import 'dart:io';
import 'package:youtube_clone/providers/video_provider.dart';
import 'package:youtube_clone/screens/home_screen.dart';
import 'package:youtube_clone/utils/getter.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
_ProfileScreenState createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final ImagePicker _picker = ImagePicker();
XFile? _thumbnailFile;
XFile? _videoFile;
VideoPlayerController? _videoPlayerController;
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Row(children: [
const Spacer(),
ElevatedButton(
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
await userProvider.logout();
},
child: const Text('Logout'),
)
]),
backgroundColor: Colors.black,
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(
'${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(userProvider.user?['username'],
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold)),
Text('@${userProvider.user?['username']}',
style: const TextStyle(color: Colors.grey)),
Text(
'${userProvider.user?['subscribers']?.length ?? 0} subscribers • ${userProvider.user?['videos']?.length ?? 0} videos',
style: const TextStyle(color: Colors.grey)),
],
),
),
],
),
const SizedBox(height: 16),
Text(
userProvider.user?['bio'] ?? '',
style: TextStyle(color: Colors.white),
),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18.0),
),
),
child: Text('Manage videos'),
onPressed: _showAddVideoModal,
),
),
],
),
],
),
),
),
);
}
void _showAddVideoModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
final videoProvider =
Provider.of<VideoProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context);
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return SingleChildScrollView(
child: Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
),
TextField(
controller: _descriptionController,
decoration: InputDecoration(labelText: 'Description'),
maxLines: 3,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery);
setState(() {
_thumbnailFile = image;
});
},
child: Text('Pick Thumbnail Image'),
),
SizedBox(height: 8),
_thumbnailFile != null
? Image.file(File(_thumbnailFile!.path), height: 100)
: Container(),
SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
final XFile? video = await _picker.pickVideo(
source: ImageSource.gallery);
setState(() {
_videoFile = video;
if (video != null) {
_videoPlayerController =
VideoPlayerController.file(File(video.path))
..initialize().then((_) {
setState(() {});
});
}
});
},
child: Text('Pick Video File'),
),
SizedBox(height: 8),
_videoFile != null
? AspectRatio(
aspectRatio:
_videoPlayerController?.value.aspectRatio ??
16 / 9,
child: VideoPlayer(_videoPlayerController!),
)
: Container(),
SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
if (_thumbnailFile != null && _videoFile != null) {
await videoProvider.uploadFile(
File(_thumbnailFile!.path),
File(_videoFile!.path),
_titleController.text,
_descriptionController.text,
userProvider.user?['documentId']);
Navigator.pop(context); // Close the modal
} else {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Missing Files'),
content: const Text(
'Please select both an image and a video.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
},
child: Text('Upload Video'),
),
],
),
),
);
},
);
},
);
}
@override
void dispose() {
_videoPlayerController?.dispose();
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
}
In the provided ProfilePage
code, we've created a user profile page with functionality to manage and upload videos. The profile page displays user information such as profile picture, username, subscriber count, and bio. It includes an option to manage videos, which opens a modal bottom sheet for uploading new videos. Within this modal, users can pick a thumbnail image and video file, preview them, and upload them through the VideoProvider
. The code also handles the video playback preview using VideoPlayerController
.
Update the _build
widget in the lib/screens/home_screen.dart
file to add navigation to the ProfileScreen
widget:
//...
import 'package:youtube_clone/screens/profile_screen.dart';
//...
@override
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Image.asset(
'assets/images/YouTube_logo.png',
width: 90,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
showSearch(
context: context,
delegate: VideoSearchDelegate(),
);
},
),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ProfileScreen()),
);
},
child: userProvider.token != null
// ignore: dead_code
? CircleAvatar(
backgroundImage: NetworkImage(
'${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
),
radius: 20,
)
// ignore: dead_code
: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
);
},
child: const Text('Sign in'),
),
),
],
),
),
body: Column(
children: [
Expanded(
child: Consumer<VideoProvider>(
builder: (context, videoProvider, child) {
if (videoProvider.videos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: videoProvider.videos.length,
itemBuilder: (context, index) {
final video = videoProvider.videos[index];
return _buildVideoTitle(video);
},
);
},
),
),
],
),
);
}
//...
Now once you sign up or sign in, you will be able to access the ProfileScreen
from the HomeScreen
widget.
Series Wrap Up
In this “Building a Youtube Clone with Strapi CMS and Flutter” blog series, here’s a summary of what we learned:
- How to set up the Strapi CMS backend with collections, create data relationships, create custom endpoints for liking, commenting, and viewing videos, set up Socket.io, and create lifecycle methods to listen to real-time updates on the collections
- How to set up a new Flutter project, configure permissions, create the app services, and state management to handle real-time functionalities and UI updates.
- How to build the front end with Flutter and consume the APIs to implement a functional YouTube clone application.
The complete code for this tutorial is available here on my Github repository. I hope you enjoyed this series. Happy coding!