← Back to Blog

Building MaxStream: A Cross-Platform Movie Streaming App with Flutter

February 20, 20249 min read
FlutterDartFirebaseMobileAPI Integration

Building MaxStream: A Cross-Platform Movie Streaming App with Flutter

Building a media streaming application requires handling real-time data, efficient UI rendering, and seamless animations. This article explores the architecture and implementation of MaxStream, a cross-platform movie and TV series streaming app built with Flutter.

#Why Flutter for Streaming?

The choice between native and cross-platform development is crucial. I chose Flutter for MaxStream because:

  • Single Codebase: Deploy to iOS, Android, and Web from one codebase
  • Performance: Dart compilation provides near-native performance
  • Hot Reload: Faster development and iteration
  • Rich Widgets: Beautiful animations and gesture handling out-of-the-box
  • Growing Ecosystem: More libraries for streaming apps

#Architecture Overview

UI Layer (Screens) ↓ State Management (Provider/BLoC) ↓ Repository Layer ↓ Data Sources (API, Firebase, SQLite)

This separation ensures: - Easy testing (mock data sources) - Clear responsibilities - Flexible data management - Scalable codebase

#Setting Up the Project

# pubspec.yaml
dependencies:
  flutter: sdk: flutter
  provider: ^6.0.0
  http: ^1.1.0
  firebase_auth: ^4.0.0
  firebase_core: ^2.0.0
  sqflite: ^2.3.0
  cached_network_image: ^3.3.0
  
dev_dependencies:
  flutter_test:
    sdk: flutter

#API Integration with TMDb

##Configuring the API Client

class TMDbClient {
  static const String baseUrl = 'https://api.themoviedb.org/3';
  final String apiKey = dotenv.env['TMDB_API_KEY']!;
  
  Future<List<Movie>> getTrendingMovies({int page = 1}) async {
    final response = await http.get(
      Uri.parse('$baseUrl/trending/movie/week?api_key=$apiKey&page=$page'),
    );
    
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      return (data['results'] as List)
          .map((movie) => Movie.fromJson(movie))
          .toList();
    }
    
    throw Exception('Failed to load trending movies');
  }
}

##Data Models

class Movie {
  final int id;
  final String title;
  final String overview;
  final String posterPath;
  final String backdropPath;
  final double voteAverage;
  final DateTime releaseDate;
  
  Movie({
    required this.id,
    required this.title,
    required this.overview,
    required this.posterPath,
    required this.backdropPath,
    required this.voteAverage,
    required this.releaseDate,
  });
  
  factory Movie.fromJson(Map<String, dynamic> json) {
    return Movie(
      id: json['id'],
      title: json['title'],
      overview: json['overview'],
      posterPath: json['poster_path'],
      backdropPath: json['backdrop_path'],
      voteAverage: (json['vote_average'] ?? 0).toDouble(),
      releaseDate: DateTime.parse(json['release_date'] ?? '2000-01-01'),
    );
  }
}

#State Management with Provider

##Movie Provider

class MovieProvider with ChangeNotifier {
  final TMDbClient tmdbClient;
  List<Movie> trendingMovies = [];
  List<Movie> upcomingMovies = [];
  bool isLoading = false;
  String? error;
  
  MovieProvider(this.tmdbClient);
  
  Future<void> fetchTrendingMovies() async {
    isLoading = true;
    notifyListeners();
    
    try {
      trendingMovies = await tmdbClient.getTrendingMovies();
      error = null;
    } catch (e) {
      error = e.toString();
    }
    
    isLoading = false;
    notifyListeners();
  }
  
  Future<void> fetchUpcomingMovies() async {
    try {
      upcomingMovies = await tmdbClient.getUpcomingMovies();
    } catch (e) {
      error = e.toString();
    }
    notifyListeners();
  }
}

##Watchlist Provider

class WatchlistProvider with ChangeNotifier {
  final WatchlistRepository repository;
  List<Movie> watchlist = [];
  
  WatchlistProvider(this.repository) {
    loadWatchlist();
  }
  
  Future<void> addToWatchlist(Movie movie) async {
    await repository.addMovie(movie);
    watchlist.add(movie);
    notifyListeners();
  }
  
  Future<void> removeFromWatchlist(int movieId) async {
    await repository.deleteMovie(movieId);
    watchlist.removeWhere((m) => m.id == movieId);
    notifyListeners();
  }
  
  bool isInWatchlist(int movieId) {
    return watchlist.any((m) => m.id == movieId);
  }
  
  Future<void> loadWatchlist() async {
    watchlist = await repository.getAllMovies();
    notifyListeners();
  }
}

#UI Implementation

##Hero Animation for Movie Posters

Hero animations create fluid transitions between screens:

class MovieCard extends StatelessWidget {
  final Movie movie;
  
  const MovieCard({required this.movie});
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => MovieDetailScreen(movie: movie),
          ),
        );
      },
      child: Hero(
        tag: 'movie_${movie.id}',
        child: ClipRRect(
          borderRadius: BorderRadius.circular(12),
          child: CachedNetworkImage(
            imageUrl: 'https://image.tmdb.org/t/p/w500${movie.posterPath}',
            placeholder: (context, url) => ShimmerLoader(),
            errorWidget: (context, url, error) => Icon(Icons.error),
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }
}

##Infinite Scroll Pagination

class MovieListScreen extends StatefulWidget {
  @override
  State<MovieListScreen> createState() => _MovieListScreenState();

class _MovieListScreenState extends State { late ScrollController _scrollController; int currentPage = 1; @override void initState() { super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); } void _onScroll() { if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) { // Load more movies context.read().fetchNextPage(++currentPage); } } @override Widget build(BuildContext context) { return Consumer( builder: (context, movieProvider, _) { return ListView.builder( controller: _scrollController, itemCount: movieProvider.movies.length + 1, itemBuilder: (context, index) { if (index == movieProvider.movies.length) { return Center(child: CircularProgressIndicator()); } return MovieCard(movie: movieProvider.movies[index]); }, ); }, ); } } ```

##Dark Mode Support

class ThemeProvider with ChangeNotifier {
  bool isDarkMode = false;
  
  ThemeData get themeData {
    return isDarkMode ? _darkTheme : _lightTheme;
  }
  
  static final _lightTheme = ThemeData(
    brightness: Brightness.light,
    primaryColor: Colors.blue,
    scaffoldBackgroundColor: Colors.white,
  );
  
  static final _darkTheme = ThemeData(
    brightness: Brightness.dark,
    primaryColor: Colors.blueAccent,
    scaffoldBackgroundColor: Color(0xFF121212),
  );
  
  void toggleTheme() {
    isDarkMode = !isDarkMode;
    notifyListeners();
  }
}

#Local Storage with SQLite

##Database Setup

class WatchlistDatabase {
  static final instance = WatchlistDatabase._init();
  
  static Database? _database;
  
  WatchlistDatabase._init();
  
  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDb('watchlist.db');
    return _database!;
  }
  
  Future<Database> _initDb(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);
    
    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDb,
    );
  }
  
  Future _createDb(Database db, int version) async {
    const idType = 'INTEGER PRIMARY KEY';
    const textType = 'TEXT NOT NULL';
    const integerType = 'INTEGER NOT NULL';
    
    await db.execute('''CREATE TABLE watchlist (
      id $idType,
      title $textType,
      posterPath $textType,
      overview $textType,
      releaseDate $textType,
      voteAverage REAL NOT NULL,
      addedDate DATETIME DEFAULT CURRENT_TIMESTAMP
    )''');
  }
}

##Repository Pattern

class WatchlistRepository {
  final WatchlistDatabase _database;
  
  WatchlistRepository(this._database);
  
  Future<void> addMovie(Movie movie) async {
    final db = await _database.database;
    await db.insert('watchlist', {
      'id': movie.id,
      'title': movie.title,
      'posterPath': movie.posterPath,
      'overview': movie.overview,
      'releaseDate': movie.releaseDate.toIso8601String(),
      'voteAverage': movie.voteAverage,
    });
  }
  
  Future<List<Movie>> getAllMovies() async {
    final db = await _database.database;
    final results = await db.query('watchlist');
    
    return results
        .map((json) => Movie(
          id: json['id'] as int,
          title: json['title'] as String,
        ))
        .toList();
  }
}

#Firebase Authentication

##User Authentication Setup

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  
  Future<UserCredential> signUpWithEmail(String email, String password) async {
    try {
      return await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );
    } catch (e) {
      throw CustomAuthException(e.toString());
    }
  }
  
  Future<UserCredential> signInWithEmail(String email, String password) async {
    try {
      return await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
    } catch (e) {
      throw CustomAuthException(e.toString());
    }
  }
  
  Future<void> signOut() async {
    await _auth.signOut();
  }
}

#Shimmer Loading

While images load, display a shimmer effect:

class ShimmerLoader extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Shimmer.fromColors(
      baseColor: Colors.grey[400]!,
      highlightColor: Colors.grey[200]!,
      child: Container(
        color: Colors.grey[400],
      ),
    );
  }
}

#Performance Optimization

##Image Caching

Use cached_network_image package:

CachedNetworkImage(
  imageUrl: moviePosterUrl,
  cacheManager: CacheManager(
    Config(
      'movie_posters',
      stalePeriod: Duration(days: 30),
      maxNrOfCacheObjects: 200,
    ),
  ),
)

##Lazy Loading Lists

Load images only when visible:

class LazyLoadingMovieList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LazyLoadScrollView(
      onEndOfPage: () {
        // Load next page
      },
      child: ListView.builder(
        itemBuilder: (context, index) {
          return MovieCard(movie: movies[index]);
        },
      ),
    );
  }
}

#Challenges & Solutions

Challenge 1: Network Image Loading

Loading hundreds of movie posters from TMDb can be slow and memory-intensive.

Solution: - Used CachedNetworkImage with 30-day cache - Compressed images on disk - Limited concurrent image loads

Challenge 2: Scroll Performance

Long lists would lag, especially on older devices.

Solution: - Implemented addRepaintBoundaries: false in ListView - Used itemExtent to calculate list height efficiently - Lazy-loaded images only in viewport

Challenge 3: Offline Support

Users want to access saved watchlists offline.

Solution: - SQLite local database for watchlist - Sync strategy: store pending changes, sync when online

Challenge 4: State Management Complexity

Multiple providers could cause unnecessary rebuilds.

Solution: - Used Consumer widget selectively - Separated concerns (Movies, Watchlist, Theme providers) - Implemented proper ChangeNotifier disposal

#Testing

void main() {
  group('MovieProvider', () {
    test('fetchTrendingMovies updates state', () async {
      final mockClient = MockTMDbClient();
      final provider = MovieProvider(mockClient);
      
      await provider.fetchTrendingMovies();
      
      expect(provider.trendingMovies.isNotEmpty, true);
      expect(provider.isLoading, false);
    });
  });
}

#Results

  • Load Time: App launches in less than 3 seconds
  • Memory Usage: Stable at 150-200MB
  • Scroll Performance: 60 FPS on mid-range devices
  • Platform Coverage: iOS, Android, and Web

#Key Learnings

1. Provider Pattern Scales: Clean separation of concerns prevents spaghetti code 2. Image Caching is Critical: Network requests are the bottleneck 3. SQLite is Powerful: Local storage enables offline functionality 4. Hot Reload Accelerates Development: Instant feedback loop 5. Testing is Essential: Unit tests catch logic errors early

#Tech Stack

  • Framework: Flutter
  • Language: Dart
  • State Management: Provider
  • API: TMDb REST API
  • Database: SQLite (local), Firebase (user data)
  • Authentication: Firebase Auth
  • Caching: CachedNetworkImage

#What I'd Do Differently

  • Use Riverpod instead of Provider (better type safety)
  • Implement GetIt for service locator
  • Add more comprehensive error handling
  • Use Freezed for immutable data classes

Building MaxStream reinforced the power of cross-platform development with Flutter. One codebase, three platforms, and beautiful animations out-of-the-box make it an excellent choice for media streaming applications.

The code is open-source on GitHub—check it out for reference!

Have thoughts on this article? Share them with me on Facebook or GitHub.