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:
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:
1📦youtube_clone
2┣ 📂tests: Test files to check app functionality.
3┣ 📂assets: General assets like images or fonts.
4┣ 📂ios: iOS-specific files.
5┣ 📂android: Android-specific files.
6┣ 📂assets
7 ┃ ┗ 📂images: Stores image assets.
8 📂lib
9 ┃ ┣ 📂providers: Handles state for WebSocket, user, and video data.
10 ┃ ┃ ┣ 📜socket_provider.dart: Manages WebSocket data.
11 ┃ ┃ ┣ 📜user_provider.dart: Manages user state.
12 ┃ ┃ ┗ 📜video_provider.dart: Manages video state.
13 ┃ ┣ 📂services: Deals with API calls for users and videos.
14 ┃ ┃ ┣ 📜user_service.dart: User API logic.
15 ┃ ┃ ┗ 📜video_service.dart: Video API logic.
16 ┃ ┣ 📂utils: Useful functions.
17 ┃ ┃ ┗ 📜getter.dart: Fetching data helpers.
18 ┃ ┗ 📜main.dart: App's starting point.
19 ┣ 📂web: Web-specific files.
20 ┣ 📂windows: Windows-specific files.
21 ┣ 📜analysis_options.yaml: Code analysis settings.
22 ┣ 📜package-lock.json: Locks npm dependencies.
23 ┣ 📜pubspec.lock: Locks Dart package versions.
24 ┗ 📜pubspec.yaml: Project settings and dependencies.
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.
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:
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3import 'package:youtube_clone/providers/user_provider.dart';
4import 'package:youtube_clone/providers/video_provider.dart';
5import 'package:youtube_clone/utils/getter.dart';
6
7class HomeScreen extends StatefulWidget {
8 const HomeScreen({super.key});
9
10
11 _HomeScreenState createState() => _HomeScreenState();
12}
13
14class _HomeScreenState extends State<HomeScreen> {
15
16 void initState() {
17 super.initState();
18 WidgetsBinding.instance.addPostFrameCallback((_) {
19 Provider.of<VideoProvider>(context, listen: false).fetchVideos();
20 });
21 }
22
23
24 Widget build(BuildContext context) {
25 final userProvider = Provider.of<UserProvider>(context);
26 return Scaffold(
27 appBar: AppBar(
28 title: Row(
29 children: [
30 Image.asset(
31 'assets/images/YouTube_logo.png',
32 width: 90,
33 ),
34 const Spacer(),
35 IconButton(
36 icon: const Icon(Icons.search, color: Colors.white),
37 onPressed: () {
38 showSearch(
39 context: context,
40 delegate: VideoSearchDelegate(),
41 );
42 },
43 ),
44 GestureDetector(
45 onTap: () {},
46 child: userProvider.token != null
47 ? CircleAvatar(
48 backgroundImage: NetworkImage(
49 '${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
50 ),
51 radius: 20,
52 )
53 : ElevatedButton(
54 onPressed: () {},
55 child: const Text('Sign in'),
56 ),
57 ),
58 ],
59 ),
60 ),
61 body: Column(
62 children: [
63 Expanded(
64 child: Consumer<VideoProvider>(
65 builder: (context, videoProvider, child) {
66 if (videoProvider.videos.isEmpty) {
67 return const Center(child: CircularProgressIndicator());
68 }
69
70 return ListView.builder(
71 itemCount: videoProvider.videos.length,
72 itemBuilder: (context, index) {
73 final video = videoProvider.videos[index];
74 return _buildVideoTitle(video);
75 },
76 );
77 },
78 ),
79 ),
80 ],
81 ),
82 );
83 }
84
85 Widget _buildVideoTitle(Map<String, dynamic> video) {
86 return Column(
87 crossAxisAlignment: CrossAxisAlignment.start,
88 children: [
89 Stack(
90 children: [
91 Image.network(
92 '${getBaseUrl()}${video['thumbnail']['url']}',
93 width: double.infinity,
94 height: 200,
95 fit: BoxFit.cover,
96 ),
97 ],
98 ),
99 ListTile(
100 contentPadding:
101 const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
102 leading: CircleAvatar(
103 backgroundImage: NetworkImage(
104 '${getBaseUrl()}${video['uploader']['profile_picture']['url']}',
105 ),
106 radius: 20,
107 ),
108 title: Text(
109 video['title'],
110 maxLines: 2,
111 overflow: TextOverflow.ellipsis,
112 ),
113 subtitle: Text(
114 '${video['uploader']['username']} • ${video['views']?.length.toString()} views • ${_formatDaysAgo(video['publishedAt'])}',
115 ),
116 trailing: IconButton(
117 icon: const Icon(Icons.more_vert),
118 onPressed: () {
119 },
120 ),
121 ),
122 ],
123 );
124 }
125
126 String _formatDaysAgo(String publishedAt) {
127 final publishedDate = DateTime.parse(publishedAt);
128 final now = DateTime.now();
129 final difference = now.difference(publishedDate).inDays;
130
131 if (difference == 0) {
132 return 'Today';
133 } else if (difference == 1) {
134 return 'Yesterday';
135 } else {
136 return '$difference days ago';
137 }
138 }
139}
140
141class VideoSearchDelegate extends SearchDelegate<String> {
142
143 List<Widget> buildActions(BuildContext context) {
144 return [
145 IconButton(
146 icon: const Icon(Icons.clear),
147 onPressed: () {
148 query = '';
149 },
150 ),
151 ];
152 }
153
154
155 Widget buildLeading(BuildContext context) {
156 return IconButton(
157 icon: const Icon(Icons.arrow_back),
158 onPressed: () {
159 close(context, '');
160 },
161 );
162 }
163
164
165 Widget buildResults(BuildContext context) {
166 final videoProvider = Provider.of<VideoProvider>(context, listen: false);
167 final results = videoProvider.videos
168 .where((video) =>
169 video['title'].toLowerCase().contains(query.toLowerCase()))
170 .toList();
171
172 return ListView.builder(
173 itemCount: results.length,
174 itemBuilder: (context, index) {
175 final video = results[index];
176 return ListTile(
177 title: Text(video['title']),
178 onTap: () async {
179 await Provider.of<VideoProvider>(context, listen: false)
180 .increaseViews(video['documentId']);
181
182 Navigator.push(
183 context,
184 MaterialPageRoute(
185 builder: (context) =>
186 VideoPlayerScreen(videoId: video['documentId']),
187 ),
188 );
189 },
190 );
191 },
192 );
193 }
194
195
196 Widget buildSuggestions(BuildContext context) {
197 final videoProvider = Provider.of<VideoProvider>(context, listen: false);
198 final suggestions = videoProvider.videos
199 .where((video) =>
200 video['title'].toLowerCase().contains(query.toLowerCase()))
201 .toList();
202
203 return ListView.builder(
204 itemCount: suggestions.length,
205 itemBuilder: (context, index) {
206 final suggestion = suggestions[index];
207 return ListTitle(
208 title: Text(suggestion['title']),
209 onTap: () {
210 query = suggestion['title'];
211 showResults(context);
212 },
213 );
214 },
215 );
216 }
217}
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:
1//...
2import 'package:youtube_clone/screens/home_screen.dart';
3
4//...
5
6class MyApp extends StatelessWidget {
7 const MyApp({super.key});
8
9
10 Widget build(BuildContext context) {
11 //...
12 return MaterialApp(
13 //...
14 home: const HomeScreen(),
15 );
16 }
17}
Create a new video_player_screen.dart
file in the lib/screens
folder and add the code below for the Video player screen.
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3import 'package:video_player/video_player.dart';
4import 'package:youtube_clone/providers/user_provider.dart';
5import 'package:youtube_clone/providers/video_provider.dart';
6import 'package:youtube_clone/utils/getter.dart';
7
8class VideoPlayerScreen extends StatefulWidget {
9 final String videoId;
10
11 const VideoPlayerScreen({super.key, required this.videoId});
12
13
14 _VideoPlayerScreenState createState() => _VideoPlayerScreenState();
15}
16
17class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
18 late VideoPlayerController _controller;
19 final TextEditingController _commentController = TextEditingController();
20
21
22 void initState() {
23 super.initState();
24 _initializeVideo();
25 }
26
27
28 void didUpdateWidget(covariant VideoPlayerScreen oldWidget) {
29 super.didUpdateWidget(oldWidget);
30 if (widget.videoId != oldWidget.videoId) {
31 _initializeVideo();
32 }
33 }
34
35 void _initializeVideo() {
36 final videoProvider = Provider.of<VideoProvider>(context, listen: false);
37 final video = videoProvider.videos
38 .firstWhere((v) => v['documentId'] == widget.videoId, orElse: () => {});
39 print('${getBaseUrl()}${video['video_file']['url']}');
40 if (video.isNotEmpty) {
41 _controller = VideoPlayerController.network(
42 '${getBaseUrl()}${video['video_file']['url']}')
43 ..initialize().then((_) {
44 if (mounted) {
45 setState(() {});
46 }
47 });
48 }
49 }
50
51
52 void dispose() {
53 _controller.dispose();
54 _commentController.dispose();
55 super.dispose();
56 }
57
58 void _submitComment(BuildContext context, String videoId) {
59 if (_commentController.text.isNotEmpty) {
60 final videoProvider = Provider.of<VideoProvider>(context, listen: false);
61 final userProvider = Provider.of<UserProvider>(context, listen: false);
62 videoProvider.commentOnVideo(
63 videoId, _commentController.text, userProvider.user?['documentId']);
64 _commentController.clear();
65 setState(() {});
66 }
67 }
68
69
70 Widget build(BuildContext context) {
71 final userProvider = Provider.of<UserProvider>(context, listen: false);
72 return Consumer<VideoProvider>(
73 builder: (context, videoProvider, child) {
74 final video = videoProvider.videos.firstWhere(
75 (v) => v['documentId'] == widget.videoId,
76 orElse: () => {});
77 if (video.isEmpty) {
78 return Scaffold(
79 appBar: AppBar(
80 leading: IconButton(
81 icon: const Icon(Icons.arrow_back),
82 onPressed: () {
83 Navigator.pop(context);
84 },
85 ),
86 ),
87 body: Center(child: Text('Video not found')),
88 );
89 }
90
91 return Scaffold(
92 appBar: AppBar(
93 leading: IconButton(
94 icon: const Icon(Icons.arrow_back),
95 onPressed: () {
96 Navigator.pop(context);
97 },
98 ),
99 ),
100 body: Stack(
101 children: [
102 SingleChildScrollView(
103 child: Column(
104 crossAxisAlignment: CrossAxisAlignment.start,
105 children: [
106 if (_controller.value.isInitialized)
107 AspectRatio(
108 aspectRatio: _controller.value.aspectRatio,
109 child: VideoPlayer(_controller),
110 ),
111 Padding(
112 padding: const EdgeInsets.all(16.0),
113 child: Column(
114 crossAxisAlignment: CrossAxisAlignment.start,
115 children: [
116 Row(
117 mainAxisAlignment: MainAxisAlignment.spaceBetween,
118 children: [
119 IconButton(
120 icon: Icon(
121 _controller.value.isPlaying
122 ? Icons.pause
123 : Icons.play_arrow,
124 ),
125 onPressed: () {
126 setState(() {
127 _controller.value.isPlaying
128 ? _controller.pause()
129 : _controller.play();
130 });
131 },
132 ),
133 IconButton(
134 icon: const Icon(Icons.fullscreen),
135 onPressed: () {
136 Navigator.push(
137 context,
138 MaterialPageRoute(
139 builder: (context) =>
140 FullscreenVideoPlayer(
141 controller: _controller),
142 ),
143 );
144 },
145 ),
146 ],
147 ),
148 const SizedBox(height: 8),
149 Text(
150 video['title'] ?? 'No Title',
151 style: const TextStyle(
152 fontSize: 16, fontWeight: FontWeight.bold),
153 ),
154 const SizedBox(height: 4),
155 Text(
156 video['description'] ?? 'No Description',
157 style: const TextStyle(fontSize: 14),
158 ),
159 const SizedBox(height: 16),
160 Row(children: [
161 CircleAvatar(
162 backgroundImage: NetworkImage(
163 '${getBaseUrl()}${video['uploader']['profile_picture']['url']}'),
164 ),
165 const SizedBox(width: 8),
166 Text(
167 (video['uploader']['username']).toString(),
168 ),
169 const SizedBox(width: 10),
170 Text(
171 (video['uploader']['subscribers']?.length ?? 0)
172 .toString(),
173 ),
174 const SizedBox(width: 10),
175 Row(
176 children: [
177 TextButton(
178 onPressed: () async {
179 await videoProvider
180 .likeVideo(video['documentId']);
181 },
182 child: const Icon(Icons.thumb_up),
183 ),
184 Text(video['likes'].length.toString()),
185 ],
186 ),
187 const SizedBox(width: 15),
188 // Check if the user is logged in
189 if (userProvider.user != null)
190 ElevatedButton(
191 onPressed: () async {
192 await videoProvider.subscribeToChannel(
193 video['uploader']['id']);
194 },
195 child: Text(
196 video['uploader']['subscribers'] != null &&
197 video['uploader']['subscribers']!.any(
198 (subscriber) =>
199 subscriber['id'] ==
200 userProvider.user!['id'])
201 ? "Unsubscribe"
202 : "Subscribe",
203 ),
204 ),
205 ]),
206 ],
207 ),
208 ),
209 Row(
210 children: [
211 const Text("Comments"),
212 const SizedBox(width: 8),
213 Text(video['comments'].length.toString()),
214 ],
215 ),
216
217 Container(
218 padding: const EdgeInsets.all(16.0),
219 color: Colors.black12,
220 child: Column(
221 children: video['comments'].map<Widget>((comment) {
222 return ListTile(
223 leading: CircleAvatar(
224 backgroundImage: NetworkImage(
225 '${getBaseUrl()}${comment['user']['profile_picture']['url']}'),
226 ),
227 title: Text(comment['user']['username']),
228 subtitle: Text(comment['text']),
229 );
230 }).toList(),
231 ),
232 ),
233 const SizedBox(height: 70),
234 ],
235 ),
236 ),
237 if (userProvider.token != null)
238 Positioned(
239 left: 0,
240 right: 0,
241 bottom: 0,
242 child: Container(
243 color: Theme.of(context).scaffoldBackgroundColor,
244 padding: const EdgeInsets.all(15),
245 child: Row(
246 children: [
247 Expanded(
248 child: TextField(
249 controller: _commentController,
250 decoration: const InputDecoration(
251 hintText: 'Add a comment...',
252 border: OutlineInputBorder(),
253 ),
254 ),
255 ),
256 const SizedBox(width: 8),
257 ElevatedButton(
258 onPressed: () =>
259 _submitComment(context, video['documentId']),
260 child: const Text('Post'),
261 ),
262 ],
263 ),
264 ),
265 ),
266 ],
267 ),
268 );
269 },
270 );
271 }
272}
273
274class FullscreenVideoPlayer extends StatelessWidget {
275 final VideoPlayerController controller;
276
277 const FullscreenVideoPlayer({super.key, required this.controller});
278
279
280 Widget build(BuildContext context) {
281 return Scaffold(
282 body: Center(
283 child: AspectRatio(
284 aspectRatio: controller.value.aspectRatio,
285 child: VideoPlayer(controller),
286 ),
287 ),
288 );
289 }
290}
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.
1 //...
2 import 'package:youtube_clone/screens/video_player_screen.dart';
3
4
5 //...
6 Widget _buildVideoTitle(Map<String, dynamic> video) {
7 return Column(
8 crossAxisAlignment: CrossAxisAlignment.start,
9 children: [
10 GestureDetector(
11 onTap: () async {
12 await Provider.of<VideoProvider>(context, listen: false)
13 .increaseViews(video['documentId']);
14
15 Navigator.push(
16 context,
17 MaterialPageRoute(
18 builder: (context) =>
19 VideoPlayerScreen(videoId: video['documentId']),
20 ),
21 );
22 },
23 child: Stack(
24 children: [
25 Image.network(
26 '${getBaseUrl()}${video['thumbnail']['url']}',
27 width: double.infinity,
28 height: 200,
29 fit: BoxFit.cover,
30 ),
31 ],
32 ),
33 ),
34 ListTile(
35 contentPadding:
36 const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
37 leading: CircleAvatar(
38 backgroundImage: NetworkImage(
39 '${getBaseUrl()}${video['uploader']['profile_picture']['url']}',
40 ),
41 radius: 20,
42 ),
43 title: GestureDetector(
44 onTap: () async {
45 await Provider.of<VideoProvider>(context, listen: false)
46 .increaseViews(video['documentId']);
47
48 Navigator.push(
49 context,
50 MaterialPageRoute(
51 builder: (context) =>
52 VideoPlayerScreen(videoId: video['documentId']),
53 ),
54 );
55 },
56 child: Text(
57 video['title'],
58 maxLines: 2,
59 overflow: TextOverflow.ellipsis,
60 ),
61 ),
62 subtitle: Text(
63 '${video['uploader']['username']} • ${video['views']?.length.toString()} views • ${_formatDaysAgo(video['publishedAt'])}',
64 ),
65 trailing: IconButton(
66 icon: const Icon(Icons.more_vert),
67 onPressed: () {},
68 ),
69 onTap: () async {
70 await Provider.of<VideoProvider>(context, listen: false)
71 .increaseViews(video['documentId']);
72
73 Navigator.push(
74 context,
75 MaterialPageRoute(
76 builder: (context) =>
77 VideoPlayerScreen(videoId: video['documentId']),
78 ),
79 );
80 },
81 ),
82 ],
83 );
84 }
85 //...
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.
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:
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3import 'package:youtube_clone/providers/user_provider.dart';
4import 'package:youtube_clone/screens/home_screen.dart';
5import 'package:image_picker/image_picker.dart';
6import 'dart:io';
7
8class AuthScreen extends StatefulWidget {
9 const AuthScreen({super.key});
10
11
12 _AuthScreenState createState() => _AuthScreenState();
13}
14
15class _AuthScreenState extends State<AuthScreen> {
16 final TextEditingController _emailController = TextEditingController();
17 final TextEditingController _passwordController = TextEditingController();
18 final TextEditingController _usernameController = TextEditingController();
19 XFile? _profilePicture;
20 bool _isLogin = true;
21
22
23 Widget build(BuildContext context) {
24 final userProvider = Provider.of<UserProvider>(context);
25 final ImagePicker _picker = ImagePicker();
26
27 return Scaffold(
28 appBar: AppBar(
29 title: Text(_isLogin ? 'Login' : 'Sign Up'),
30 ),
31 body: Padding(
32 padding: const EdgeInsets.all(16.0),
33 child: SingleChildScrollView(
34 child: Column(
35 children: [
36 if (!_isLogin)
37 TextField(
38 controller: _usernameController,
39 decoration: const InputDecoration(labelText: 'Username'),
40 ),
41 TextField(
42 controller: _emailController,
43 decoration: const InputDecoration(labelText: 'Email'),
44 ),
45 TextField(
46 controller: _passwordController,
47 decoration: const InputDecoration(labelText: 'Password'),
48 obscureText: true,
49 ),
50 const SizedBox(height: 20),
51 if (!_isLogin)
52 _profilePicture == null
53 ? TextButton(
54 onPressed: () async {
55 final pickedFile = await _picker.pickImage(
56 source: ImageSource.gallery);
57 setState(() {
58 _profilePicture = pickedFile;
59 });
60 },
61 child: const Text('Select Profile Picture'),
62 )
63 : Column(
64 children: [
65 Image.file(
66 File(_profilePicture!.path),
67 height: 100,
68 width: 100,
69 ),
70 TextButton(
71 onPressed: () async {
72 final pickedFile = await _picker.pickImage(
73 source: ImageSource.gallery);
74 setState(() {
75 _profilePicture = pickedFile;
76 });
77 },
78 child: const Text('Change Profile Picture'),
79 ),
80 ],
81 ),
82 const SizedBox(height: 20),
83 ElevatedButton(
84 onPressed: () async {
85 if (_isLogin) {
86 await userProvider.login(
87 _emailController.text,
88 _passwordController.text,
89 );
90 } else {
91 await userProvider.signup(
92 File(_profilePicture!.path),
93 _emailController.text,
94 _usernameController.text,
95 _passwordController.text,
96 );
97 }
98
99 if (userProvider.token != null) {
100 Navigator.pushAndRemoveUntil(
101 context,
102 MaterialPageRoute(
103 builder: (context) => const HomeScreen()),
104 (Route<dynamic> route) => false,
105 );
106 }
107 },
108 child: Text(_isLogin ? 'Login' : 'Sign Up'),
109 ),
110 const SizedBox(height: 20),
111 TextButton(
112 onPressed: () {
113 setState(() {
114 _isLogin = !_isLogin;
115 });
116 },
117 child: Text(_isLogin
118 ? 'Don\'t have an account? Sign Up'
119 : 'Already have an account? Login'),
120 ),
121 const SizedBox(height: 20),
122 Text(userProvider.message ?? "")
123 ],
124 ),
125 ),
126 ),
127 );
128 }
129}
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:
1//...
2import 'package:youtube_clone/screens/auth_screen.dart';
3
4 //...
5
6 Widget build(BuildContext context) {
7 final userProvider = Provider.of<UserProvider>(context);
8 return Scaffold(
9 appBar: AppBar(
10 title: Row(
11 children: [
12 Image.asset(
13 'assets/images/YouTube_logo.png',
14 width: 90,
15 ),
16 const Spacer(),
17 IconButton(
18 icon: const Icon(Icons.search, color: Colors.white),
19 onPressed: () {
20 showSearch(
21 context: context,
22 delegate: VideoSearchDelegate(),
23 );
24 },
25 ),
26 GestureDetector(
27 child: userProvider.token != null
28 ? CircleAvatar(
29 backgroundImage: NetworkImage(
30 '${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
31 ),
32 radius: 20,
33 )
34 : ElevatedButton(
35 onPressed: () {
36 Navigator.push(
37 context,
38 MaterialPageRoute(builder: (context) => const AuthScreen()),
39 );
40 },
41 child: const Text('Sign in'),
42 ),
43 ),
44 ],
45 ),
46 ),
47 body: Column(
48 children: [
49 // _buildFilterBar(),
50 Expanded(
51 child: Consumer<VideoProvider>(
52 builder: (context, videoProvider, child) {
53 if (videoProvider.videos.isEmpty) {
54 return const Center(child: CircularProgressIndicator());
55 }
56
57 return ListView.builder(
58 itemCount: videoProvider.videos.length,
59 itemBuilder: (context, index) {
60 final video = videoProvider.videos[index];
61 return _buildVideoTile(video);
62 },
63 );
64 },
65 ),
66 ),
67 ],
68 ),
69 );
70 }
71 //...
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.
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:
1import 'package:flutter/material.dart';
2import 'package:image_picker/image_picker.dart';
3import 'package:provider/provider.dart';
4import 'package:video_player/video_player.dart';
5import 'package:youtube_clone/providers/user_provider.dart';
6import 'dart:io';
7import 'package:youtube_clone/providers/video_provider.dart';
8import 'package:youtube_clone/screens/home_screen.dart';
9import 'package:youtube_clone/utils/getter.dart';
10
11class ProfileScreen extends StatefulWidget {
12 const ProfileScreen({super.key});
13
14
15 _ProfileScreenState createState() => _ProfileScreenState();
16}
17
18class _ProfileScreenState extends State<ProfileScreen> {
19 final ImagePicker _picker = ImagePicker();
20 XFile? _thumbnailFile;
21 XFile? _videoFile;
22 VideoPlayerController? _videoPlayerController;
23 final _titleController = TextEditingController();
24 final _descriptionController = TextEditingController();
25
26
27 Widget build(BuildContext context) {
28 final userProvider = Provider.of<UserProvider>(context);
29
30 return Scaffold(
31 backgroundColor: Colors.black,
32 appBar: AppBar(
33 title: Row(children: [
34 const Spacer(),
35 ElevatedButton(
36 onPressed: () async {
37 Navigator.push(
38 context,
39 MaterialPageRoute(builder: (context) => const HomeScreen()),
40 );
41 await userProvider.logout();
42 },
43 child: const Text('Logout'),
44 )
45 ]),
46 backgroundColor: Colors.black,
47 ),
48 body: SingleChildScrollView(
49 child: Padding(
50 padding: const EdgeInsets.all(16.0),
51 child: Column(
52 crossAxisAlignment: CrossAxisAlignment.start,
53 children: [
54 Row(
55 children: [
56 CircleAvatar(
57 radius: 40,
58 backgroundImage: NetworkImage(
59 '${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
60 ),
61 ),
62 const SizedBox(width: 16),
63 Expanded(
64 child: Column(
65 crossAxisAlignment: CrossAxisAlignment.start,
66 children: [
67 Text(userProvider.user?['username'],
68 style: const TextStyle(
69 fontSize: 24,
70 color: Colors.white,
71 fontWeight: FontWeight.bold)),
72 Text('@${userProvider.user?['username']}',
73 style: const TextStyle(color: Colors.grey)),
74 Text(
75 '${userProvider.user?['subscribers']?.length ?? 0} subscribers • ${userProvider.user?['videos']?.length ?? 0} videos',
76 style: const TextStyle(color: Colors.grey)),
77 ],
78 ),
79 ),
80 ],
81 ),
82 const SizedBox(height: 16),
83 Text(
84 userProvider.user?['bio'] ?? '',
85 style: TextStyle(color: Colors.white),
86 ),
87 SizedBox(height: 16),
88 Row(
89 children: [
90 Expanded(
91 child: ElevatedButton(
92 style: ElevatedButton.styleFrom(
93 backgroundColor: Colors.grey[800],
94 shape: RoundedRectangleBorder(
95 borderRadius: BorderRadius.circular(18.0),
96 ),
97 ),
98 child: Text('Manage videos'),
99 onPressed: _showAddVideoModal,
100 ),
101 ),
102 ],
103 ),
104 ],
105 ),
106 ),
107 ),
108 );
109 }
110
111 void _showAddVideoModal() {
112 showModalBottomSheet(
113 context: context,
114 isScrollControlled: true,
115 builder: (context) {
116 final videoProvider =
117 Provider.of<VideoProvider>(context, listen: false);
118 final userProvider = Provider.of<UserProvider>(context);
119
120 return StatefulBuilder(
121 builder: (BuildContext context, StateSetter setState) {
122 return SingleChildScrollView(
123 child: Container(
124 padding: EdgeInsets.only(
125 bottom: MediaQuery.of(context).viewInsets.bottom,
126 left: 16,
127 right: 16,
128 top: 16,
129 ),
130 child: Column(
131 mainAxisSize: MainAxisSize.min,
132 children: [
133 TextField(
134 controller: _titleController,
135 decoration: InputDecoration(labelText: 'Title'),
136 ),
137 TextField(
138 controller: _descriptionController,
139 decoration: InputDecoration(labelText: 'Description'),
140 maxLines: 3,
141 ),
142 SizedBox(height: 16),
143 ElevatedButton(
144 onPressed: () async {
145 final XFile? image = await _picker.pickImage(
146 source: ImageSource.gallery);
147 setState(() {
148 _thumbnailFile = image;
149 });
150 },
151 child: Text('Pick Thumbnail Image'),
152 ),
153 SizedBox(height: 8),
154 _thumbnailFile != null
155 ? Image.file(File(_thumbnailFile!.path), height: 100)
156 : Container(),
157 SizedBox(height: 16),
158 ElevatedButton(
159 onPressed: () async {
160 final XFile? video = await _picker.pickVideo(
161 source: ImageSource.gallery);
162 setState(() {
163 _videoFile = video;
164 if (video != null) {
165 _videoPlayerController =
166 VideoPlayerController.file(File(video.path))
167 ..initialize().then((_) {
168 setState(() {});
169 });
170 }
171 });
172 },
173 child: Text('Pick Video File'),
174 ),
175 SizedBox(height: 8),
176 _videoFile != null
177 ? AspectRatio(
178 aspectRatio:
179 _videoPlayerController?.value.aspectRatio ??
180 16 / 9,
181 child: VideoPlayer(_videoPlayerController!),
182 )
183 : Container(),
184 SizedBox(height: 16),
185 ElevatedButton(
186 onPressed: () async {
187 if (_thumbnailFile != null && _videoFile != null) {
188 await videoProvider.uploadFile(
189 File(_thumbnailFile!.path),
190 File(_videoFile!.path),
191 _titleController.text,
192 _descriptionController.text,
193 userProvider.user?['documentId']);
194 Navigator.pop(context); // Close the modal
195 } else {
196 showDialog(
197 context: context,
198 builder: (BuildContext context) {
199 return AlertDialog(
200 title: const Text('Missing Files'),
201 content: const Text(
202 'Please select both an image and a video.'),
203 actions: [
204 TextButton(
205 onPressed: () {
206 Navigator.of(context).pop();
207 },
208 child: const Text('OK'),
209 ),
210 ],
211 );
212 },
213 );
214 }
215 },
216 child: Text('Upload Video'),
217 ),
218 ],
219 ),
220 ),
221 );
222 },
223 );
224 },
225 );
226 }
227
228
229 void dispose() {
230 _videoPlayerController?.dispose();
231 _titleController.dispose();
232 _descriptionController.dispose();
233 super.dispose();
234 }
235}
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:
1//...
2import 'package:youtube_clone/screens/profile_screen.dart';
3
4 //...
5
6 Widget build(BuildContext context) {
7 final userProvider = Provider.of<UserProvider>(context);
8 return Scaffold(
9 appBar: AppBar(
10 title: Row(
11 children: [
12 Image.asset(
13 'assets/images/YouTube_logo.png',
14 width: 90,
15 ),
16 const Spacer(),
17 IconButton(
18 icon: const Icon(Icons.search, color: Colors.white),
19 onPressed: () {
20 showSearch(
21 context: context,
22 delegate: VideoSearchDelegate(),
23 );
24 },
25 ),
26 GestureDetector(
27 onTap: () {
28 Navigator.push(
29 context,
30 MaterialPageRoute(builder: (context) => const ProfileScreen()),
31 );
32 },
33 child: userProvider.token != null
34 // ignore: dead_code
35 ? CircleAvatar(
36 backgroundImage: NetworkImage(
37 '${getBaseUrl()}${userProvider.user?['profile_picture']['url']}',
38 ),
39 radius: 20,
40 )
41 // ignore: dead_code
42 : ElevatedButton(
43 onPressed: () {
44 Navigator.push(
45 context,
46 MaterialPageRoute(builder: (context) => const AuthScreen()),
47 );
48 },
49 child: const Text('Sign in'),
50 ),
51 ),
52 ],
53 ),
54 ),
55 body: Column(
56 children: [
57 Expanded(
58 child: Consumer<VideoProvider>(
59 builder: (context, videoProvider, child) {
60 if (videoProvider.videos.isEmpty) {
61 return const Center(child: CircularProgressIndicator());
62 }
63
64 return ListView.builder(
65 itemCount: videoProvider.videos.length,
66 itemBuilder: (context, index) {
67 final video = videoProvider.videos[index];
68 return _buildVideoTitle(video);
69 },
70 );
71 },
72 ),
73 ),
74 ],
75 ),
76 );
77 }
78 //...
Now once you sign up or sign in, you will be able to access the ProfileScreen
from the HomeScreen
widget.
In this "Building a Youtube Clone with Strapi CMS and Flutter" blog series, here's a summary of what we learned:
The complete code for this tutorial is available here on my Github repository. I hope you enjoyed this series. Happy coding!
I am Software Engineer and Technical Writer. Proficient Server-side scripting and Database setup. Agile knowledge of Python, NodeJS, ReactJS, and PHP. When am not coding, I share my knowledge and experience with the other developers through technical articles