Build a Youtube Clone with Strapi and Flutter: Part 2

Strapi - Oct 23 - - Dev Community

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:

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
  //...
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
 //...
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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")),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .