Building MaxStream: A Cross-Platform Movie Streaming App with Flutter
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
##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!