Introduction
This is a continuation of the blog series: "Building a Youtube Clone with Strapi and Flutter". It is suggested that you read Part 1 to understand how we got to this stage.
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 this Part 2, we'll learn 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.
To get started, let's set up a new Flutter project, configure the development environment, and install the required dependencies for this project.
Creating a New Flutter Project
Create a new flutter project in the terminal by running the commands below.
flutter create youtube_clone
cd youtube_clone
flutter run
Setting up Android and IOS Permissions
First, we need to configure the permissions to grant users access to the internet, read and write external storage, and use the camera.
For Android, open the android/app/src/main/AndroidManifest.xml
file and add the necessary permissions to your existing AndroidManifest.xml
file:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permission for internet access -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Permissions for accessing external storage -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Permission for camera access -->
<uses-permission android:name="android.permission.CAMERA"/>
<application
...
android:usesCleartextTraffic="true"
...>
...
</application>
</manifest>
Then for iOS, open the ios/Runner/Info.plist
file and add the necessary permissions into your existing ios/Runner/Info.plist
file:
...
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow access to photo library</string>
<key>NSCameraUsageDescription</key>
<string>Allow access to camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow access to microphone</string>
...
</dict>
</plist>
After making these changes, rebuild and run your app.
Adding necessary dependencies
Next, add the necessary dependencies for this project. We'll install the following dependencies:
- http: Provides a set of high-level functions and classes for HTTP requests.
- video_player: Flutter plugin for playing video on Android, iOS, and web.
- web_socket_channel: Provides WebSocket support for Dart applications.
- provider: A wrapper around InheritedWidget for easier state management.
- image_picker: Flutter plugin for selecting images from the device's gallery or camera.
- socket_io_client: Dart client for Socket.IO, enabling real-time, bidirectional communication.
- path: Provides common operations for manipulating paths across platforms.
Run the command below on your terminal from the root directory of your Flutter project to install the dependencies:
flutter pub add http video_player web_socket_channel provider image_picker socket_io_client path flutter_secure_storage
Configuring Asset files
Finally, configure the project assets files that will be used in this project. Create a new directory named assets
in the root directory of your project. In the assets
directory, create an images
directory to keep the images. Then download the YouTube logo into that directory.
Open the pubspec.yaml
file from the root directory of your project, locate the assets
section, and add assets to your application:
assets:
- assets/images/YouTube_logo.png
Lastly, create a utils
folder in the lib
folder, inside the utils
folder create a new getter.dart
file and add the code snippets:
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
String getBaseUrl() {
if (kIsWeb) {
return 'http://localhost:1337';
} else if (Platform.isAndroid) {
return 'http://10.0.2.2:1337';
} else if (Platform.isIOS) {
return 'http://localhost:1337';
} else {
return 'http://localhost:1337';
}
}
The getBaseUrl()
function checks which device the app is running on and returns the appropriate host for that device. This ensures that our application works on any platform.
Creating App Services
Now that you have set up your Flutter project, configured the permissions, add assets, and installed the required project dependencies, let's proceed to creating the app services to make API requests to your Strapi backend.
Creating a Video Service
Create a new folder named services
in the lib
directory. In the services
directory, create a new file named video_service.dart
. Define a VideoService
class and add a fetchVideos
method to fetch video data from your Strapi backend:
import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:youtube_clone/utils/getter.dart';
class VideoService {
static const String populateQuery =
'populate[comments][populate][user][populate][profile_picture]=profile_picture&populate[thumbnail]=*&populate[video_file]=*&populate[likes]=*&populate[views]=*&populate[comments][populate]=*&populate[uploader][populate]=profile_picture&populate[uploader][populate]=subscribers';
final _storage = const FlutterSecureStorage();
Future<List<dynamic>> fetchVideos() async {
final response =
await http.get(Uri.parse('${getBaseUrl()}/api/videos?$populateQuery'));
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
// Extract the list of videos from the 'data' field
return responseData['data'] as List<dynamic>;
} else {
throw Exception('Failed to load videos');
}
}
Future<Map<String, dynamic>> fetchVideo(String documentId) async {
final response = await http
.get(Uri.parse('$getBaseUrl()/api/videos/$documentId/$populateQuery'));
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
// Extract the list of videos from the 'data' field
return responseData['data'] as Map<String, dynamic>;
} else {
throw Exception('Failed to load videos');
}
}
}
In the above code VideoService
we defined two global variables, populateQuery
for all the populate queries we'll use to populate all the relations models in your Video collection. The fetchVideos
method sends a GET
request to fetch all videos, while fetchVideo
retrieves a single video by its documentId
. Both methods use the populateQuery
for all the required data retrieval, including comments, user information, thumbnails, etc.
Next, update the VideoService
to define a new method to handle the video uploading. Also since this is an authenticated action we'll need a JWT token, so we'll define a new method to extract the token from the device's local storage (the logic for saving the token will be handled when implementing authentication):
//...
Future<String?> _getAuthToken() async {
return await _storage.read(key: 'auth_token');
}
Future<void> uploadVideoContent(File videoFile, File thumbnailFile,
String title, String description, String userId) async {
try {
final baseUrl = getBaseUrl();
final videoId = await uploadFile(videoFile, 'video');
final thumbnailId = await uploadFile(thumbnailFile, 'image');
await createVideoEntry(
baseUrl, videoId, thumbnailId, title, description, userId);
} catch (e) {
print('Error occurred: $e');
}
}
Future<String> uploadFile(File file, String fileType) async {
final uri = Uri.parse('${getBaseUrl()}/api/upload');
var request = http.MultipartRequest('POST', uri);
request.files.add(await http.MultipartFile.fromPath('files', file.path));
var response = await request.send();
if (response.statusCode == 201) {
final responseBody = await response.stream.bytesToString();
final jsonResponse = jsonDecode(responseBody);
return jsonResponse[0]['id'].toString();
} else {
throw Exception('Failed to upload $fileType');
}
}
Future<void> createVideoEntry(String baseUrl, String videoId,
String thumbnailId, String title, String description, userId) async {
final uri = Uri.parse('$baseUrl/api/videos');
final token = await _getAuthToken();
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
"data": {
"title": title,
"description": description,
"video_file": videoId,
"thumbnail": thumbnailId,
"uploader": userId
}
}),
);
if (response.statusCode == 201) {
print('Video entry created successfully');
} else {
print('Failed to create video entry: ${response.body}');
throw Exception('Failed to create video entry');
}
}
//...
In these code snippets, we've added some new methods to our VideoService
class to handle uploading videos and creating entries. The _getAuthToken
method grabs the auth token from secure storage (we'll need this for some requests).
The uploadVideoContent
method is the main player here. It manages the whole process of uploading a video file and its thumbnail, then creates a new entry in the backend. It uses the uploadFile
method to send the files to the server and get their IDs back.
Then we have the createVideoEntry
method. This takes all the info we've collected, video ID, thumbnail ID, title, description, and user's ID, and creates a new video entry in our Strapi backend. It uses the auth token we retrieved earlier. We've also added error handling throughout to catch and log any problems that might pop up during the upload and creation process.
Lastly, add the following methods to the VideoService
class to handle the like, views, comment and subscribe requests to your Strapi backend:
//...
Future<void> likeVideo(String videoId) async {
final token = await _getAuthToken();
final response = await http.put(
Uri.parse('${getBaseUrl()}/api/videos/$videoId/like'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode != 200) {
throw Exception('Failed to like video');
}
}
Future<void> increaseViews(String videoId) async {
final token = await _getAuthToken();
final response = await http.put(
Uri.parse('${getBaseUrl()}/api/videos/$videoId/increment-view'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode != 200) {
throw Exception(
'Failed to increase views: ${response.reasonPhrase} (${response.statusCode})');
}
}
Future<void> commentOnVideo(
String videoId, String comment, String userId) async {
final token = await _getAuthToken();
final response = await http.post(
Uri.parse('${getBaseUrl()}/api/comments?populate=*'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
"data": {'text': comment, "user": userId, "video": videoId}
}),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to post comment: ${response.body}');
}
}
Future subscribeToChannel(int userId) async {
final uri = Uri.parse('${getBaseUrl()}/api/videos/$userId/subscribe');
final token = await _getAuthToken();
final response = await http.put(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
print('Subscription created successfully');
} else {
print('Failed to create subscription: ${response.body}');
throw Exception('Failed to create subscription');
}
}
//...
In this part of the code, we've defined a few more methods to VideoService
class to handle some common video interactions such as liking, increasing views, commenting, and subscribing to a channel.
First, we've defined the likeVideo
method to send a request to the server to like a specific video. We use the auth
token to make sure the user is logged in. Then the increaseViews
method to increase the view count for a video. Again, we're using the auth token to keep things secure. Lastly, define the commentOnVideo
method to allow users to post comments on videos. It sends the video ID, the comment text, and the user ID to the server.
Creating a User Service
To allow users to sign-up to create a channel and log in. You'll need to create a user service. Create a new user_service.dart
file in lib/services
directory and add the code snippets below:
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:youtube_clone/services/video_service.dart';
import 'dart:convert';
import 'package:youtube_clone/utils/getter.dart';
class UserService {
Future<String?> login(String email, String password) async {
final response = await http.post(
Uri.parse('${getBaseUrl()}/api/auth/local'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'identifier': email,
'password': password,
}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['jwt'];
} else {
return null;
}
}
Future<Map?> me(String jwtToken) async {
final response = await http.get(
Uri.parse('${getBaseUrl()}/api/users/me?populate=role,profile_picture,'),
headers: {
'Content-Type': 'application/json',
"Authorization": 'Bearer $jwtToken',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data;
} else {
return null;
}
}
Future<String?> signup(File profilePicturefile, String email, String username,
String password) async {
try {
final profilePictureid =
await VideoService().uploadFile(profilePicturefile, 'image');
final response = await http.post(
Uri.parse('${getBaseUrl()}/api/auth/local/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username,
'password': password,
"email": email,
'profile_picture': profilePictureid
}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['jwt'];
} else {
return null;
}
} catch (e) {
print('Error occurred: $e');
}
return null;
}
}
In the above code snippets, we created a new UserService
class to handle user-related operations. We designed a login
method that sends a request to the Strapi authentication endpoint to log in to a user using their email
and password
. If it works, we get back a JWT token that we can use for future requests.
Then we defined a me
method that fetches the current user's info from the server, including their role and profile picture. Lastly, we defined a signup
method, which takes a profile picture file along with the user's email, username, and password. First, it uploads the profile picture using our VideoService
. Then it sends all this info to the server to create a new user account. If everything goes well, we get back a JWT token for the new user.
State Management with Provider
Now that we've created the App services, we need to keep track of the data change over time and make sure all parts of our app stay in sync. We'll be using the Flutter state management package called Provider.
Creating the Socket Provider
To handle the real-time functionalities of the application, like users being able to see uploaded videos, comments, likes, and subscriptions in real-time, we need to create a Socket provider to listen to all the events we defined in your collections and update the UI accordingly. Create a new socket_provider.dart
file in the provider's directory and add the code snippets below:
import 'package:flutter/material.dart';
import 'package:socket_io_client/socket_io_client.dart' as IO;
import 'package:youtube_clone/utils/getter.dart';
class SocketProvider with ChangeNotifier {
IO.Socket? _socket;
bool _isConnected = false;
bool get isConnected => _isConnected;
void connect(BuildContext context) {
_socket = IO.io(getBaseUrl(), {
'transports': ['websocket'],
'autoConnect': false,
});
_socket?.on('connect', (_) {
_isConnected = true;
notifyListeners();
});
_socket?.on('disconnect', (_) {
_isConnected = false;
notifyListeners();
});
// Listen for video events and update the VideoProvider
_socket?.on('video.created', (_) => notifyListeners());
_socket?.on('video.updated', (_) => notifyListeners());
_socket?.on('video.deleted', (_) => notifyListeners());
_socket?.on('comment.created', (_) => notifyListeners());
_socket?.on('user.updated', (_) => notifyListeners());
_socket?.on('user.created', (_) => notifyListeners());
_socket?.connect();
}
void disconnect() {
_socket?.disconnect();
}
}
In the above code, we created a SocketProvider
class using Flutter's ChangeNotifier
to manage a WebSocket connection with the Strapi server. This setup will allow your application to receive real-time updates from the Video, Comment, and User collections.
Creating Video Provider
To manage the state in our app for all the video-related data and operations, create a VideoProvider
class. Create a new directory named providers
in your lib
directory. Then create a video_provider.dart
in our providers
folder and add the code below:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:youtube/providers/socket_provider.dart';
import 'package:youtube/services/video_service.dart';
class VideoProvider with ChangeNotifier {
List _videos = [];
Map _video = {};
List get videos => _videos;
VideoProvider(BuildContext context) {
final socketProvider = Provider.of<SocketProvider>(context, listen: false);
socketProvider.addListener(_handleSocketEvents);
}
void _handleSocketEvents() {
fetchVideos();
}
Future fetchVideos() async {
try {
_videos = await VideoService().fetchVideos();
notifyListeners();
} catch (error) {
print("Error fetching videos: $error");
}
}
Future fetchVideo(String documentId) async {
try {
_video = await VideoService().fetchVideo(documentId);
notifyListeners();
} catch (error) {
print("Error fetching videos: $error");
}
}
Future likeVideo(String videoId) async {
try {
await VideoService().likeVideo(videoId);
notifyListeners();
} catch (error) {
print("Error liking video: $error");
}
}
Future increaseViews(String videoId) async {
try {
await VideoService().increaseViews(videoId);
notifyListeners();
} catch (error) {
print("Error increasing views: $error");
}
}
Future commentOnVideo(String videoId, String comment, String userId) async {
try {
await VideoService().commentOnVideo(videoId, comment, userId);
notifyListeners();
} catch (error) {
print("Error commenting on video: $error");
}
}
Future uploadFile(
File imageFile, File videoFile, String title, String description, String userId) async {
try {
await VideoService().uploadVideoContent(
videoFile,
imageFile,
title,
description,
userId
);
notifyListeners();
} catch (error) {
print("Error uploading file: $error");
}
}
Future subscribeToChannel(int documentId) async {
try {
await VideoService().subscribeToChannel(documentId);
notifyListeners();
} catch (error) {
print("Error subscribing to channel: $error");
}
}
}
Here the fetchVideos
and fetchVideo
methods get videos from our VideoService
, update the local _videos
and _video
lists, and then call notifyListeners()
which tells the rest of our app that the data has changed and the UI needs to update. Also, the likeVideo
, increaseViews
,commentOnVideo
, uploadFile
, and subscribeToChannel
methods call their corresponding VideoService
methods and updates the UI.
Creating the User Provider
Create a new user_provider.dart
file in the providers folder and add the code snippets below:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:youtube/services/user_service.dart';
class UserProvider with ChangeNotifier {
final _storage = const FlutterSecureStorage();
String? _token;
String? _message;
Map<String, dynamic>? _user;
String? get token => _token;
String? get message => _message;
Map<String, dynamic>? get user => _user;
Future<void> login(String email, String password) async {
try {
_token = await UserService().login(email, password);
if (_token != null) {
_user = (await UserService().me(_token!)) as Map<String, dynamic>?;
await _storage.write(key: 'auth_token', value: _token);
_message = null;
notifyListeners();
} else {
_message = 'Invalid email or password';
notifyListeners();
}
} catch (e) {
_message = 'Failed to login. Please try again later.';
notifyListeners();
}
}
Future<void> signup(File profilePicturefile, String email, String username,
String password) async {
try {
_token = await UserService()
.signup(profilePicturefile, email, username, password);
if (_token != null) {
_user = (await UserService().me(_token!)) as Map<String, dynamic>?;
await _storage.write(key: 'auth_token', value: _token);
_message = null;
notifyListeners();
} else {
_message = 'Signup failed. Please check your details and try again.';
notifyListeners();
}
} catch (e) {
print(e);
_message = 'Failed to sign up. Please try again later.';
notifyListeners();
}
}
Future<void> loadToken() async {
_token = await _storage.read(key: 'auth_token');
notifyListeners();
}
Future<void> logout() async {
_token = null;
_user = {};
await _storage.delete(key: 'auth_token');
notifyListeners();
}
}
In the code provided above, we created a UserProvider
class using Flutter's ChangeNotifier
to manage user authentication and state. It interacts with a UserService
to perform login
, and signup
, and fetch user details.
Now update your main.dart
file with the code snippets below:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:youtube_clone/providers/socket_provider.dart';
import 'package:youtube_clone/providers/user_provider.dart';
import 'package:youtube_clone/providers/video_provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => SocketProvider()),
ChangeNotifierProvider(create: (_) => UserProvider()),
ChangeNotifierProvider(
create: (context) => VideoProvider(context),
),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Connect to the socket when the app starts
Provider.of<SocketProvider>(context, listen: false).connect(context);
return MaterialApp(
title: 'YouTube Clone',
theme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.black,
scaffoldBackgroundColor: Colors.black,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.black,
elevation: 0,
iconTheme: IconThemeData(color: Colors.white),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
iconTheme: const IconThemeData(color: Colors.white),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white70),
titleMedium: TextStyle(color: Colors.white),
titleSmall: TextStyle(color: Colors.white70),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.grey[850],
labelStyle: const TextStyle(color: Colors.white),
secondaryLabelStyle: const TextStyle(color: Colors.white),
brightness: Brightness.dark,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
cardColor: Colors.grey[900],
dividerColor: Colors.grey[700],
),
home: const Center(child: Text("My YouTube Clone")),
);
}
}
In the above code, we integrated the SocketProvider
, UserProvider
, and VideoProvider
using MultiProvider
, ensuring these services are available across the app. We also initialized a socket connection at the app launch by invoking the connect
method from the SocketProvider
in the MyApp
widget.
We also configured the MaterialApp
with a dark theme to create a consistent, YouTube-like UI throughout the application.
We're done with Part 2 of this blog series. Stay tuned for Part 3, where we'll continue this tutorial by building the frontend with Flutter and consuming the APIs to implement a functional YouTube clone application.
Conclusion
In Part 2 of this tutorial series, we've learned how to set up a new Flutter project, configure permissions, create the app services, and configure Flutter state management to handle real-time functionalities and UI updates.
The code for this project is available on Github.
In the last part of this blog series, we will learn how to build the app UI with Flutter.